docs/SY-FORMAT.md
.sy File JSON Structure — AI Read/Write GuideCanonical Spec baseline:
2(the current writer output; compatible readers may encounter an older or missingSpecand upgrade it). Verified against samples:20200825162036-4dx365o.sy(formatting elements),20200905090211-2vixtlf.sy(block types). All conclusions are based on real samples and the current Lute / SiYuan kernel source. The cited samples contain a few known legacy artifacts; canonical write rules follow the current source when a sample differs. This guide describes plaintext.syJSON in an ordinary notebook, or the decrypted AST of an unlocked encrypted notebook. An encrypted notebook's on-disk.syfile is ciphertext and must not be edited as JSON. Companion document:WORKSPACE.mdcovers the overall on-disk layout of the workspace (how notebooks, parent/child documents, and assets are organized); this document focuses on the internal JSON structure of a.syfile.
A plaintext .sy file is a Lute AST tree serialized to JSON. The root node is NodeDocument; the body is the recursively nested Children array. There is no separately maintained JSON Schema — the Lute ast.Node and ListData Go structs are the serialization source of truth. The tree contains the document AST and its IAL, while assets, AttributeView definitions, and rebuildable indexes live outside the tree.
This guide distinguishes the format that new writers should emit from historical data that the kernel can tolerate and normalize:
| Term | Meaning |
|---|---|
| Required | Required in newly generated canonical Spec 2 data |
| Optional | May be omitted because the field is empty or carries omitempty |
| Compatible input | Historical or external data that the reader may accept and preserve, repair, or upgrade according to explicit compatibility rules |
Unless a section explicitly says otherwise, "required" refers to canonical new writes. dataparser.ParseJSON is a compatibility reader rather than a strict schema validator: for example, it can add a missing empty paragraph, assign a missing block ID, and upgrade an old Spec.
.sy directly (priority order)SiYuan offers three official paths to mutate data: HTTP API, MCP, and CLI. Prefer them by default. The kernel handles AST serialization, block-ID allocation, and synchronization of two indexes: the block-tree index (blocktree.db, the block-ID → file-path map that block refs and breadcrumbs depend on) and the full-text search index (siyuan.db + FTS5). Writing the files directly bypasses all of this and easily leaves the indexes out of sync.
Only read/write .sy as JSON when the official paths are inconvenient. Applicable scenarios:
WORKSPACE.md)Division of labor among the four paths:
| Path | Role | Mutation capability |
|---|---|---|
| HTTP API | Online, at runtime | Richest — full CRUD on docs/blocks (filetree/*, block/*, transactions) |
| MCP | LLM tool set | Subset for AI agents operating on docs online |
| CLI | Batch / ops | Import, export, sync, SQL, and other command-line tasks |
Read/write .sy directly | The scope of this guide | Offline, bulk, low-level structural work |
⚠️ After writing files directly you usually need a "rebuild index" pass before search/block-refs become effective. If SiYuan is running, prefer the HTTP API and let the kernel handle serialization and index sync. ⚠️ Do not directly mutate encrypted-notebook persistent files. Use the dedicated APIs after unlocking the notebook so encryption, authentication, and isolated indexes remain consistent.
{
"ID": "20200825162036-4dx365o",
"Spec": "2",
"Type": "NodeDocument",
"Properties": {
"icon": "1f4f0",
"id": "20200825162036-4dx365o",
"title": "排版元素",
"type": "doc",
"updated": "20260616224229"
},
"Children": [ ... ]
}
| Top-level key | Required | Meaning |
|---|---|---|
ID | ✅ | Document block ID. Equals the filename without .sy |
Spec | ✅ | "2" in canonical current files; older or missing values are compatible input and may be upgraded |
Type | ✅ | "NodeDocument" |
Properties | ✅ | Document-level IAL — see §8 |
Children | ✅ | Array of body child blocks; canonical files contain at least one block |
⚠️ The file path strictly corresponds to the root ID:
data/<box>/<...>/<rootID>.sy. Changing the root ID means renaming the file — don't change it casually. For the full file-system layout seeWORKSPACE.md. A compatible reader inserts an empty paragraph whenChildrenis missing or empty, but new writers should emit that paragraph themselves.
| Field | Type | Presence | Meaning |
|---|---|---|---|
Type | string | required on every node | Type discriminator, e.g. "NodeParagraph" |
ID | string | required on canonical block nodes; may occur on compatible non-block input | 22-char block ID for blocks; canonical writers do not add it to inline/marker nodes |
Data | string | some | Text / HTML / markdown raw; may be omitted (don't assume it exists) |
Properties | object | blocks and some inline nodes | IAL, map[string]string; inline uses include styled text, images, and table cells |
Children | array | containers and structurally composite nodes | Child node array |
| Type-specific fields | - | per type | e.g. HeadingLevel, ListData, TextMarkType, AttributeViewID |
Core discriminator rule: Type determines whether a node is a block (ast.Node.IsBlock() is authoritative); the presence of ID does not. In canonical Spec 2 data, every block has an ID and matching Properties.id, while new inline/marker nodes have neither. Historical files produced by old bugs may contain IDs on non-block nodes such as NodeCodeBlockCode or NodeMathBlockContent. Compatible readers and editors may remove those legacy ID / Properties.id fields during normalization, but must classify the node by Type and must not delete the node merely because its ID conflicts with the canonical rule.
YYYYMMDDHHMMSS-xxxxxxx = 14-digit timestamp + - + 7 random [a-z0-9] chars. Example: 20210104091228-ttcj9nm.Properties.updated is the same 14-digit timestamp; semantics: "last updated time".Properties.updated on the changed block, its block-level ancestors, applicable preceding headings, and the document root.ID, you must sync Properties.id. Its Properties.updated must be no earlier than the creation time encoded by the new ID.updated; canonical new writes should always include it on block nodes.Leaf blocks: NodeParagraph, NodeHeading, NodeThematicBreak, NodeHTMLBlock, NodeCodeBlock, NodeMathBlock, NodeTable, NodeBlockQueryEmbed, NodeAttributeView, NodeIFrame, NodeVideo, NodeAudio, NodeWidget, NodeCustomBlock
Container blocks: NodeList, NodeListItem, NodeBlockquote, NodeCallout, NodeSuperBlock
NodeText, NodeTextMark, NodeImage, NodeKramdownSpanIAL, NodeSoftBreak, NodeBr, NodeBackslash, NodeBackslashContent, NodeHeadingC8hMarker, NodeBlockquoteMarker, NodeTaskListItemMarker, NodeBang, NodeOpenBracket, NodeCloseBracket, NodeOpenParen, NodeCloseParen, NodeLinkText, NodeLinkDest, NodeLinkSpace, NodeLinkTitle, NodeCodeBlockCode, NodeCodeBlockFenceOpenMarker, NodeCodeBlockFenceInfoMarker, NodeCodeBlockFenceCloseMarker, NodeMathBlockContent, NodeMathBlockOpenMarker, NodeMathBlockCloseMarker, NodeSuperBlockOpenMarker, NodeSuperBlockLayoutMarker, NodeSuperBlockCloseMarker, NodeOpenBrace, NodeCloseBrace, NodeBlockQueryEmbedScript, NodeTableHead, NodeTableRow, NodeTableCell
"Leaf block" means the node cannot contain other block nodes. A leaf may still have structural inline children, as code blocks, math blocks, and tables do. The heading above is a canonical-write rule. An explicit normalization pass may remove an
IDalready present on a compatible historical non-block node, but must not use that field to decide whether the node is a block or whether the node itself should be removed. Types excluded from canonical writes — including parser-disabled syntax and the detection-onlyNodeGitConflictfamily — are listed in §11 and intentionally omitted from this catalog.
{ "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "...", "updated": "..." },
"Children": [ { "Type": "NodeText", "Data": "This is a sample paragraph." } ] }
{ "Type": "NodeHeading", "ID": "...", "HeadingLevel": 2,
"Properties": { "id": "...", "updated": "..." },
"Children": [ { "Type": "NodeText", "Data": "Heading" } ] }
HeadingLevel ranges 1–6.NodeHeadingC8hMarker (Data such as "## ") is optional — present or absent, both are legal. Recommend omitting it for brevity when generating.ListData.Typ)★ Canonical structural constraint of lists: direct children of
NodeListcan only beNodeListItem(CanContainreturnsNodeListItem == nodeType). Paragraphs, code blocks, sub-lists, or any other block cannot be attached directly underNodeList— they must be wrapped in aNodeListItemfirst.dataparser.ParseJSONdoes not enforce this as a strict validation step, so direct writers must validate the structure themselves.
✅ Correct ❌ Wrong
NodeList NodeList
└─ NodeListItem ├─ NodeParagraph ← illegal
└─ NodeParagraph └─ NodeCodeBlock ← illegal
Nested lists are written by wrapping another NodeList (NodeListItem falls into the default CanContain branch and cannot directly contain another NodeListItem):
✅ Correct ❌ Wrong
NodeList NodeList
└─ NodeListItem └─ NodeListItem
├─ NodeParagraph ├─ NodeParagraph
└─ NodeList ← sub-list └─ NodeListItem ← illegal
└─ NodeListItem
└─ NodeParagraph
Unordered list (Typ omitted):
{ "Type": "NodeList", "ID": "...", "ListData": {},
"Properties": { "id": "...", "updated": "..." },
"Children": [
{ "Type": "NodeListItem", "ID": "...",
"ListData": { "BulletChar": 42, "Marker": "Kg==" },
"Properties": { "id": "...", "updated": "..." },
"Children": [
{ "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "...", "updated": "..." },
"Children": [ { "Type": "NodeText", "Data": "Item one" } ] }
] }
] }
Ordered list (Typ: 1):
{ "Type": "NodeList", "ID": "...", "ListData": { "Typ": 1 },
"Properties": { "id": "...", "updated": "..." },
"Children": [
{ "Type": "NodeListItem", "ID": "...",
"ListData": { "Typ": 1, "Tight": true, "Start": 1, "Delimiter": 46, "Padding": 3, "Marker": "MS4=", "Num": 1 },
"Properties": { "id": "...", "updated": "..." },
"Children": [
{ "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "...", "updated": "..." },
"Children": [ { "Type": "NodeText", "Data": "Item one" } ] }
] }
] }
Task list (Typ: 3); each NodeListItem starts with a NodeTaskListItemMarker:
{ "Type": "NodeList", "ID": "...", "ListData": { "Typ": 3 },
"Properties": { "id": "...", "updated": "..." },
"Children": [
{ "Type": "NodeListItem", "ID": "...",
"ListData": { "Typ": 3, "Tight": true, "BulletChar": 45, "Padding": 2, "Marker": "LQ==", "Num": -1 },
"Properties": { "id": "...", "updated": "..." },
"Children": [
{ "Type": "NodeTaskListItemMarker", "TaskListItemChecked": true, "TaskListItemMarker": 88 },
{ "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "...", "updated": "..." },
"Children": [ { "Type": "NodeText", "Data": "Task one" } ] }
] }
] }
ListData fields in full (★ easiest to get wrong)| Field | Type (code) | JSON form | Meaning |
|---|---|---|---|
Typ | int | number | List type discriminator: omitted = unordered, 1 = ordered, 3 = task |
Tight | bool | boolean | Tight (no blank lines); optional |
BulletChar | byte | number | Bullet ASCII codepoint for unordered/task lists (42 = *, 45 = -) |
Delimiter | byte | number | Ordered-list delimiter ASCII codepoint (46 = .) |
Start | int | number | Ordered-list start number |
Num | int | number | This item's number; usually omitted or -1 for unordered/task lists |
Padding | int | number | Indent padding; optional |
MarkerOffset | int | number | Marker indentation offset; optional |
Checked | bool | boolean | Compatibility metadata derived while parsing a task marker; it is not an aggregate for the whole list and may be omitted |
Marker | []byte | base64 string | The marker text, base64-encoded; may include a delimiter ("MS4=" = 1.) or not ("MQ==" = 1) |
Key distinction:
BulletChar/Delimiterarebytein code and appear as int codepoints in JSON;Markeris[]bytein code and appears as a base64 string in JSON.Marker/BulletChar/Delimiterall carryomitemptyand may be omitted.
Checked with X:
{ "Type": "NodeTaskListItemMarker", "TaskListItemChecked": true, "TaskListItemMarker": 88 }
Unchecked with a space:
{ "Type": "NodeTaskListItemMarker", "TaskListItemMarker": 32 }
An arbitrary non-space marker such as ! is also treated as checked and preserves its original byte:
{ "Type": "NodeTaskListItemMarker", "TaskListItemChecked": true, "TaskListItemMarker": 33 }
TaskListItemMarker is a Go byte, so JSON stores its ASCII codepoint as a number. Current rendering prefers this field and falls back to TaskListItemChecked for compatible older data. Data may appear when the AST comes directly from Markdown parsing (for example "[X]"), but editor-generated .sy data usually omits it; do not use Data as the authoritative task state.
{ "Type": "NodeBlockquote", "ID": "...", "Properties": { "id": "...", "updated": "..." },
"Children": [
{ "Type": "NodeBlockquoteMarker", "Data": "> " },
{ "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "...", "updated": "..." },
"Children": [ { "Type": "NodeText", "Data": "Quoted content" } ] }
] }
NodeBlockquoteMarker.Datamay be">"or"> "— both are legal.
{ "Type": "NodeCallout", "ID": "...",
"CalloutType": "NOTE", "CalloutTitle": "Note", "CalloutIcon": "✏️",
"Properties": { "id": "...", "updated": "..." },
"Children": [ { "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "...", "updated": "..." },
"Children": [ { "Type": "NodeText", "Data": "Callout content" } ] } ] }
CalloutType | CalloutTitle | CalloutIcon |
|---|---|---|
NOTE | Note | ✏️ |
TIP | Tip | 💡 |
IMPORTANT | Important | ❗ |
WARNING | Warning | ⚠️ |
CAUTION | Caution | 🚨 |
The table lists the five built-in types and their defaults. Custom CalloutType, title, and icon values are also supported. CalloutIcon is a literal emoji for CalloutIconType: 0 (the default, omitted by omitempty); CalloutIconType: 1 means CalloutIcon is a custom icon path.
{ "Type": "NodeSuperBlock", "ID": "...", "Properties": { "id": "...", "updated": "..." },
"Children": [
{ "Type": "NodeSuperBlockOpenMarker" },
{ "Type": "NodeSuperBlockLayoutMarker", "Data": "col" },
{ "Type": "NodeSuperBlock", "ID": "...", "Properties": { "id": "...", "updated": "..." }, "Children": [ ... nested super block, Data "row" ... ] },
{ "Type": "NodeSuperBlockCloseMarker" }
] }
NodeSuperBlockLayoutMarker.Datacan only be"row"(horizontal) or"col"(vertical). A canonical super block contains the open marker, layout marker, at least one content block, and close marker — at least four children total. It may contain multiple content blocks, can nest, and is the only container that can hold any block (including itself).
{{ ... }}){ "Type": "NodeBlockQueryEmbed", "ID": "...", "Properties": { "id": "...", "updated": "..." },
"Children": [
{ "Type": "NodeOpenBrace" },
{ "Type": "NodeOpenBrace" },
{ "Type": "NodeBlockQueryEmbedScript", "Data": "select * from blocks where id='20210428212840-8rqwn5o'" },
{ "Type": "NodeCloseBrace" },
{ "Type": "NodeCloseBrace" }
] }
{ "Type": "NodeCodeBlock", "ID": "...", "IsFencedCodeBlock": true,
"CodeBlockFenceChar": 96, "CodeBlockFenceLen": 3,
"CodeBlockOpenFence": "YGBg", "CodeBlockInfo": "Z28=", "CodeBlockCloseFence": "YGBg",
"Properties": { "id": "...", "updated": "..." },
"Children": [
{ "Type": "NodeCodeBlockFenceOpenMarker", "Data": "```", "CodeBlockFenceLen": 3 },
{ "Type": "NodeCodeBlockFenceInfoMarker", "CodeBlockInfo": "Z28=" },
{ "Type": "NodeCodeBlockCode", "Data": "package main\n...\n" },
{ "Type": "NodeCodeBlockFenceCloseMarker", "Data": "```", "CodeBlockFenceLen": 3 }
] }
Notes:
NodeCodeBlockCode carries the code content (in Data, raw text with \n escaped); it's an inline child of NodeCodeBlock.CodeBlockInfo is the base64-encoded language ("Z28=" = go). The parent's six fields (IsFencedCodeBlock/CodeBlockFenceChar/CodeBlockFenceLen/CodeBlockOpenFence/CodeBlockInfo/CodeBlockCloseFence) all carry omitempty and may be omitted as needed — newer .sy files often write only "IsFencedCodeBlock": true.SetIndentCodeBlock(false)); canonical new code blocks are fenced.{ "Type": "NodeMathBlock", "ID": "...", "Properties": { "id": "...", "updated": "..." },
"Children": [
{ "Type": "NodeMathBlockOpenMarker" },
{ "Type": "NodeMathBlockContent", "Data": "a^2 + b^2 = c^2" },
{ "Type": "NodeMathBlockCloseMarker" }
] }
Data){ "Type": "NodeHTMLBlock", "ID": "...", "Data": "<div>\n<ruby>你<rt>nǐ</rt>...</div>", "Properties": { "id": "...", "updated": "..." } }
{ "Type": "NodeIFrame", "ID": "...", "Data": "<iframe src=\"...\"></iframe>", "Properties": { "id": "...", "updated": "..." } }
{ "Type": "NodeWidget", "ID": "...", "Data": "<iframe src=\"/widgets/example\" data-subtype=\"widget\"></iframe>", "Properties": { "id": "...", "updated": "..." } }
{ "Type": "NodeVideo", "ID": "...", "Data": "<video controls src=\"assets/x.mp4\"></video>", "Properties": { "id": "...", "updated": "..." } }
{ "Type": "NodeAudio", "ID": "...", "Data": "<audio controls src=\"assets/x.wav\"></audio>", "Properties": { "id": "...", "updated": "..." } }
These five have no
Children; the HTML content (JSON-escaped) goes directly in the top-levelData.
{ "Type": "NodeTable", "ID": "...", "TableAligns": [0, 0, 0],
"Properties": { "id": "...", "updated": "...", "colgroup": "||" },
"Children": [
{ "Type": "NodeTableHead", "Data": "thead", "Children": [
{ "Type": "NodeTableRow", "Data": "tr", "Children": [
{ "Type": "NodeTableCell", "Data": "th", "Children": [ { "Type": "NodeText", "Data": "Header" } ] }
] }
] },
{ "Type": "NodeTableRow", "Data": "tr", "Children": [
{ "Type": "NodeTableCell", "Data": "td", "Children": [ { "Type": "NodeText", "Data": "Cell" } ] }
] }
] }
NodeTable > NodeTableHead/NodeTableRow > NodeTableCell > inline.TableAligns: int array of per-column alignment: 0 = default, 1 = left, 2 = center, 3 = right.Data (thead/tr/th/td) may be omitted in compact files.Properties.colgroup stores a |-separated CSS style string for each column; empty segments represent columns without an explicit style.Properties.caption stores its caption HTML.NodeTableCell may carry Properties.colspan, Properties.rowspan, and Properties.style for merged-cell and cell-style state.{ "Type": "NodeAttributeView", "ID": "...",
"Properties": { "custom-sy-av-view": "20251230141609-lcme2fh", "id": "...", "updated": "..." },
"AttributeViewID": "20251230141609-2kvghrg",
"AttributeViewType": "table" }
Children.AttributeViewID points to the AV table data (stored in a separate .json — don't fabricate this ID).AttributeViewType: table / kanban / gallery, etc. This value is a derived cache of the layout selected by custom-sy-av-view and is not an independent view selector. Normal document writes may correct a stale value; rendering does not depend on it after the view is resolved.custom-sy-av-view is the sole persisted selector for this database block. When it is absent or does not identify a view in the referenced AttributeView, the first available view is used as the fallback.AI is advised not to create new AttributeView blocks, since the table data is not in the
.sy— it requires accompanying files.
{ "Type": "NodeThematicBreak", "ID": "...", "Properties": { "id": "...", "updated": "..." } }
{ "Type": "NodeCustomBlock", "ID": "...", "Data": "raw custom content",
"CustomBlockInfo": "info", "Properties": { "id": "...", "updated": "..." } }
NodeCustomBlock is a leaf with no Children. Data stores its raw content and CustomBlockInfo stores the fence info string.
NodeText (plain text){ "Type": "NodeText", "Data": "plain text" }
Data may be omitted because an empty string carries omitempty; { "Type": "NodeText" } therefore represents empty text, not U+200B. An actual zero-width space must be present in Data (for example as the JSON escape "\u200b").
NodeTextMark (the unified carrier for modern inline formatting)In .sy files, bold/italic/link/inline-code/block-ref etc. are almost all NodeTextMark, not NodeStrong/NodeEmphasis/NodeLink. TextMarkType determines the kind.
TextMarkType | Meaning | Required fields |
|---|---|---|
text | plain text | TextMarkTextContent |
strong | bold | TextMarkTextContent |
em | italic | TextMarkTextContent |
u | underline | TextMarkTextContent |
s | strikethrough (double-tilde ~~) | TextMarkTextContent |
mark | highlight | TextMarkTextContent |
sup / sub | super/subscript | TextMarkTextContent |
kbd | keyboard key | TextMarkTextContent |
code | inline code | TextMarkTextContent |
tag | tag #tag# | TextMarkTextContent |
a | hyperlink | TextMarkAHref, TextMarkTextContent (optional TextMarkATitle) |
block-ref | block reference | TextMarkBlockRefID, TextMarkBlockRefSubtype, TextMarkTextContent |
inline-math | inline math | TextMarkInlineMathContent (no TextMarkTextContent) |
inline-memo | inline note | TextMarkInlineMemoContent, TextMarkTextContent |
file-annotation-ref | file-annotation ref | TextMarkFileAnnotationRefID, TextMarkTextContent |
Samples:
{ "Type": "NodeTextMark", "TextMarkType": "a", "TextMarkAHref": "https://ld246.com", "TextMarkTextContent": "hyperlink" }
{ "Type": "NodeTextMark", "TextMarkType": "block-ref", "TextMarkBlockRefID": "20200812220555-lj3enxa", "TextMarkBlockRefSubtype": "s", "TextMarkTextContent": "block ref" }
{ "Type": "NodeTextMark", "TextMarkType": "inline-math", "TextMarkInlineMathContent": "a^2 + b^2 = c^2" }
{ "Type": "NodeTextMark", "TextMarkType": "inline-memo", "TextMarkInlineMemoContent": "an inline note", "TextMarkTextContent": "note" }
TextMarkBlockRefSubtype: "s" = static anchor text, "d" = dynamic anchor text (the anchor text follows the target block's content; note that "embed block" is a separate node NodeBlockQueryEmbed, unrelated to this).TextMarkType may stack multiple marks separated by spaces, e.g. "strong em".TextMarkTextContent is not present on every type (inline-math lacks it).~~x~~, not single-tilde ~x~ (SetGFMStrikethrough1(false)).NodeTextMark subtype: it maps to the separate NodeBackslash node and never appears as a TextMarkType value.A NodeTextMark carrying color/effects (with Properties.style) must be immediately followed by a NodeKramdownSpanIAL, and the two must share the exact same style text:
{ "Type": "NodeTextMark", "Properties": { "style": "color: var(--b3-font-color1); background-color: var(--b3-font-background1);" },
"TextMarkType": "strong", "TextMarkTextContent": "color 1" },
{ "Type": "NodeKramdownSpanIAL", "Data": "{: style=\"color: var(--b3-font-color1); background-color: var(--b3-font-background1);\"}" }
When generating styled inline text, these two nodes must appear as a pair, otherwise the kramdown round-trip will drop the style.
NodeImage (seven-part core; optional title adds two nodes){ "Type": "NodeImage", "Data": "span", "Children": [
{ "Type": "NodeBang" },
{ "Type": "NodeOpenBracket" },
{ "Type": "NodeLinkText", "Data": "alt text" },
{ "Type": "NodeCloseBracket" },
{ "Type": "NodeOpenParen" },
{ "Type": "NodeLinkDest", "Data": "assets/image-2021.png" },
{ "Type": "NodeLinkSpace" },
{ "Type": "NodeLinkTitle", "Data": "Image title" },
{ "Type": "NodeCloseParen" }
] }
Data = "span"; compatible compact data may omit an empty Data.NodeBang/NodeOpenBracket/NodeCloseBracket/NodeOpenParen/NodeCloseParen markers may omit Data.NodeLinkText and NodeLinkDest carry the alt text and destination. When a title exists, insert NodeLinkSpace and NodeLinkTitle immediately before NodeCloseParen; the seven-node form without them is also valid.{ "Type": "NodeSoftBreak", "Data": "\n" }
{ "Type": "NodeBr" }
{ "Type": "NodeBackslash",
"Children": [ { "Type": "NodeBackslashContent", "Data": "|" } ] }
NodeSoftBreak represents a soft line break.NodeBr represents an explicit .NodeBackslash wraps the escaped character as inline content; it is not a NodeTextMark subtype.| Field | Encoding | Example |
|---|---|---|
ListData.Marker | base64 | Kg== = *, MS4= = 1., MQ== = 1 |
CodeBlockInfo | base64 | Z28= = go, amF2YQ== = java |
CodeBlockOpenFence/CloseFence | base64 | YGBg = ``` |
ListData.BulletChar/Delimiter | int ASCII codepoint (not base64) | 42 = *, 46 = . |
TaskListItemMarker | int ASCII codepoint (not base64) | 32 = space, 88 = X, 33 = ! |
Data (paragraph text, code content, link, SQL, etc.) | raw (not encoded) | "package main\n..." |
Rule of thumb: Go
[]bytefields such asMarker/Fence/Infobecome base64 strings; Gobytefields such asBulletChar/Delimiter/TaskListItemMarkerbecome JSON numbers; content strings such asData,TextMarkTextContent, andTextMarkInlineMathContentremain raw strings.
A flat map[string]string.
Document-level (required in canonical writes): id, title, type (always "doc"), updated. Optional: icon (emoji codepoint hex, e.g. "1f4f0"; custom-icon filename; or HTTP(S) image URL), title-img (document title image style as a CSS declaration string, e.g. background-image:url("assets/example.jpg")).
Block-level (required in canonical writes): id (= the node's ID), updated. Compatible historical data may lack updated, but new writers should provide it. Common optional attributes include style, fold: "1", name, alias, memo, bookmark, table colgroup / caption, AttributeView custom-sy-av-view, and arbitrary custom-* attributes.
Inline-level (optional): some inline or structural nodes also use Properties, including styled NodeTextMark, positioned or sized NodeImage, and merged/styled NodeTableCell. An inline Properties object does not make the node a block.
The authoritative canonical key is lowercase
id. Some legacy imported files also carry a leftover uppercaseID; an explicit normalization pass may remove that compatibility artifact.
| Container | Can contain | Cannot contain |
|---|---|---|
NodeList | only NodeListItem | any other block (paragraphs/code blocks/sub-lists must be wrapped in NodeListItem first) |
NodeListItem | any non-NodeListItem block (paragraph/code block/sub-NodeList/super block…) | NodeListItem (nesting requires another NodeList) |
NodeBlockquote | any non-NodeListItem block + one NodeBlockquoteMarker | NodeListItem |
NodeCallout | any non-NodeListItem block | NodeListItem |
NodeSuperBlock | any block (incl. nested super blocks), inside its open/layout/close marker envelope | none (most permissive) |
NodeDocument | any non-NodeListItem block | NodeListItem |
These are canonical writer constraints derived from Lute's
CanContain. The Markdown parser applies them while building a tree, butdataparser.ParseJSONis not a strict containment validator and does not reject every violation. Direct writers must validate these relationships themselves; invalid trees can cause parse or render anomalies.
Compatible AST data may contain (U+200B) in NodeText for caret boundaries around inline elements. Preserve an existing U+200B when editing, but do not synthesize a NodeText containing U+200B on both sides of every image, inline code, tag, kbd, or similar node: Protyle injects these caret placeholders contextually while rendering the editor DOM. An omitted Data field represents an empty string, not U+200B.
Canonical writers must not generate the following syntax or node families. Most are disabled via SetXxx(false) in NewLute() (kernel/util/lute.go), so the configured Markdown parser does not generate them. NodeGitConflict is the special case: NewLute() enables SetGitConflict(true) only so existing raw Git conflict markers can be recognized; that node family is still disabled for canonical .sy writes. A compatibility reader may encounter any of these types in historical or externally produced JSON.
| Rule | Corresponding node types | Note |
|---|---|---|
Canonical-write prohibition; SetGitConflict(true) recognizes existing input | NodeGitConflict/NodeGitConflictOpenMarker/NodeGitConflictContent/NodeGitConflictCloseMarker | raw Git conflict marker block; never generate |
SetFootnotes(false) | NodeFootnotesDefBlock/NodeFootnotesDef/NodeFootnotesRef | footnotes, fully disabled |
SetToC(false) | NodeToC | [toc] table of contents |
SetIndentCodeBlock(false) | indented code blocks | only fenced code blocks are supported |
SetHeadingID(false) | NodeHeadingID | custom heading ID {#id} |
SetSetext(false) | Setext headings (===/--- underline form) | only ATX-style # is supported |
SetYamlFrontMatter(false) | NodeYamlFrontMatter | YAML front matter |
SetLinkRef(false) | NodeLinkRefDef/NodeLinkRefDefBlock | link reference definitions |
SetGFMStrikethrough1(false) | single-tilde strikethrough ~x~ | only double-tilde ~~x~~ is supported |
Note:
NewLute()also setsSetAutoSpace(false),SetCodeSyntaxHighlight(false), andSetExportNormalizeTaskListMarker(false)— these are non-syntax switches that only affect rendering/export and never remove any node type, so they're omitted from the table above.
When generating or compatibly editing a .sy that SiYuan can load cleanly, verify item by item:
Type = "NodeDocument", Spec = "2"; root ID = filename (without .sy) and equals Properties.idProperties contains id/title/type:"doc"/updatedID, matching Properties.id, and a valid 14-digit Properties.updatedType, not from ID; do not add IDs to new inline/marker nodes, and only remove historical non-block IDs as field normalization without deleting the nodeupdated on the changed block, its block ancestors, applicable preceding headings, and the document rootListData.Typ (0 or omitted = unordered / 1 = ordered / 3 = task), and both NodeList and each NodeListItem carry the appropriate TypNodeList direct children are only NodeListItem; nested lists use another NodeList inside an itembyte fields (BulletChar, Delimiter, TaskListItemMarker) are JSON numbers; Go []byte fields (Marker, fences, info) are base64 stringsTaskListItemMarker (32 = space, 88 = X, other non-space bytes are checked); TaskListItemChecked is a compatibility fallback and Data is not authoritativeNodeCodeBlockCode and NodeMathBlockContent are inline structural children; historical IDs on them may be removed without removing the nodes[]byte fields are base64-encodedNodeTextMark for modern inline formatting over legacy NodeStrong/NodeEmphasis/NodeLinkNodeTextMark is followed by its paired NodeKramdownSpanIALChildren; their content uses Data or type-specific fieldsAttributeViewID or block-reference target IDs; they must point to real AVs or blocksNodeGitConflict, footnotes, ToC, YAML, LinkRef, or HeadingID; tolerate them when compatibility-reading historical or external data| ❌ Wrong | ✅ Correct |
|---|---|
Assuming every node has Data | Data may be omitted; marker nodes often lack it |
Deciding that a node is a block because it has ID, or deleting the whole node as cleanup | Type determines block status; compatible editors may remove a historical non-block ID field without removing the node |
Using legacy nodes like NodeStrong/NodeLink | Use NodeTextMark + TextMarkType |
ListData.Typ only accepts 1 | 0 or omitted = unordered, 1 = ordered, 3 = task |
Treating BulletChar as base64 | It's byte, appearing as an int codepoint in JSON (42 = *) |
Using "Data":"[X]" as the authoritative task state | Preserve the marker byte in numeric TaskListItemMarker; TaskListItemChecked is a compatibility fallback |
Styled TextMark without the IAL | Must pair with NodeKramdownSpanIAL |
Adding Children to AttributeView, Widget, or CustomBlock nodes | They are leaves — use Data or their type-specific fields |
Changing ID without syncing Properties.id | The two must match |
| Updating only the directly edited block's timestamp | Also refresh its block ancestors, applicable preceding headings, and the document root |
inline-math carrying TextMarkTextContent | It only has TextMarkInlineMathContent |
| Fabricating block-ref / AV target IDs | Targets must really exist |
Hanging a paragraph directly under NodeList | NodeList can only contain NodeListItem — wrap first |
| Adding U+200B text nodes on both sides of every inline element | Preserve existing U+200B; let Protyle add editor-DOM caret placeholders contextually |
Generating NodeGitConflict, footnotes, ToC, YAML, etc. | They are disabled for canonical writes; compatibility readers may still encounter historical or external nodes |
⚠️ All IDs and timestamps below are illustrative. Generate fresh workspace-wide unique IDs and current timestamps; never copy these literal values into a real
.syfile.
{
"ID": "20260628120000-abc1234",
"Spec": "2",
"Type": "NodeDocument",
"Properties": {
"id": "20260628120000-abc1234",
"title": "New doc",
"type": "doc",
"updated": "20260628120000"
},
"Children": [
{
"Type": "NodeHeading", "ID": "20260628120001-def5678", "HeadingLevel": 2,
"Properties": { "id": "20260628120001-def5678", "updated": "20260628120001" },
"Children": [ { "Type": "NodeText", "Data": "Heading" } ]
},
{
"Type": "NodeParagraph", "ID": "20260628120002-ghi9012",
"Properties": { "id": "20260628120002-ghi9012", "updated": "20260628120002" },
"Children": [
{ "Type": "NodeText", "Data": "Body with " },
{ "Type": "NodeTextMark", "TextMarkType": "strong", "TextMarkTextContent": "bold" },
{ "Type": "NodeText", "Data": "." }
]
}
]
}
app/guide/20210808180117-czj9bvb/20200812220555-lj3enxa/20210808180320-abz7w6k/20200825162036-4dx365o.sy (formatting elements — covers nearly all block types)app/guide/20210808180117-czj9bvb/20200812220555-lj3enxa/20210808180320-fqgskfj/20200905090211-2vixtlf.sy (block types — incl. compact lists and AttributeView)lute/ast/node.golute/render/json_renderer.go, dataparser/sy.goast.Node.CanContainkernel/util/lute.go (NewLute), including Git-conflict input recognitionkernel/go.mod