diff --git a/SY-FORMAT.md b/SY-FORMAT.md new file mode 100644 index 000000000..7b2e7ca27 --- /dev/null +++ b/SY-FORMAT.md @@ -0,0 +1,592 @@ +# SiYuan `.sy` File JSON Structure — AI Read/Write Guide + +> Spec baseline: `2` (current across all files). +> Verified against samples: `20200825162036-4dx365o.sy` (formatting elements), `20200905090211-2vixtlf.sy` (block types). +> All conclusions are based on real samples and the Lute / SiYuan kernel source. Fields marked `【inferred】` were not directly observed in the samples — re-verify against a real sample before generating them. + +## 0. In one sentence + +A `.sy` file is a Lute AST tree serialized to JSON. The root node is always `NodeDocument`; the body is the `Children` array, recursively nested. There is no external schema — all state lives in the tree. + +--- + +## 0.5 When to read/write `.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: +- Bulk offline migration (cold-init a workspace, import external data) +- Read-only statistics, analysis, custom export / format conversion +- Repairing low-level structural issues (legacy files, illegal nodes) +- Programmatic scaffolding / template generation + +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. + +--- + +## 1. Top-level structure + +```json +{ + "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`** (not randomly generated) | +| `Spec` | ✅ | Always `"2"` | +| `Type` | ✅ | Always `"NodeDocument"` | +| `Properties` | ✅ | Document-level IAL — see §8 | +| `Children` | ✅ | Array of body child blocks; must contain at least one block | + +> ⚠️ The file path strictly corresponds to the root ID: `data//<...>/.sy`. Changing the root ID means renaming the file — don't change it casually. + +--- + +## 2. Common field semantics (apply to every node) + +| Field | Type | Presence | Meaning | +|---|---|---|---| +| `Type` | string | **required on every node** | Type discriminator, e.g. `"NodeParagraph"` | +| `ID` | string | block nodes only | 22-char block ID; inline/marker nodes **do not carry it** | +| `Data` | string | some | Text / HTML / markdown raw; **may be omitted** (don't assume it exists) | +| `Properties` | object | most blocks | IAL, `map[string]string` | +| `Children` | array | container / nestable nodes | Child node array | +| Type-specific fields | - | per type | e.g. `HeadingLevel`, `ListData`, `TextMarkType`, `AttributeViewID` | + +**Core discriminator rule:** whether a node has an `ID` determines whether it is a block. Has `ID` ⇒ block (its `Properties.id` must equal its `ID`); no `ID` ⇒ inline/marker node. + +--- + +## 3. ID and timestamp rules + +- **ID format:** `YYYYMMDDHHMMSS-xxxxxxx` = 14-digit timestamp + `-` + 7 random `[a-z0-9]` chars. Example: `20210104091228-ttcj9nm`. +- The **root ID** comes from the filename and is **not** regenerated. +- **Child block IDs** are generated with the above scheme. +- `Properties.updated` is the same 14-digit timestamp; semantics: "last updated time". +- When you change any block's `ID`, you must **sync** `Properties.id`. `Properties.updated` should be refreshed to the current time as well. + +--- + +## 4. Node-type catalog + +### Block nodes (have an ID) + +**Leaf blocks:** `NodeParagraph`, `NodeHeading`, `NodeThematicBreak`, `NodeHTMLBlock`, `NodeCodeBlock`, `NodeMathBlock`, `NodeTable`, `NodeBlockQueryEmbed`, `NodeAttributeView`, `NodeIFrame`, `NodeVideo`, `NodeAudio`, `NodeWidget`, `NodeCustomBlock`, `NodeGitConflict` + +**Container blocks:** `NodeList`, `NodeListItem`, `NodeBlockquote`, `NodeCallout`, `NodeSuperBlock` + +**Block-level content children** (live inside a block, also have an ID): `NodeCodeBlockCode`, `NodeMathBlockContent` + +### Inline / marker nodes (no ID) + +`NodeText`, `NodeTextMark`, `NodeImage`, `NodeKramdownSpanIAL`, `NodeHeadingC8hMarker`, `NodeBlockquoteMarker`, `NodeTaskListItemMarker`, `NodeBang`, `NodeOpenBracket`, `NodeCloseBracket`, `NodeOpenParen`, `NodeCloseParen`, `NodeLinkText`, `NodeLinkDest`, `NodeCodeBlockFenceOpenMarker`, `NodeCodeBlockFenceInfoMarker`, `NodeCodeBlockFenceCloseMarker`, `NodeMathBlockOpenMarker`, `NodeMathBlockCloseMarker`, `NodeSuperBlockOpenMarker`, `NodeSuperBlockLayoutMarker`, `NodeSuperBlockCloseMarker`, `NodeOpenBrace`, `NodeCloseBrace`, `NodeBlockQueryEmbedScript`, `NodeTableHead`, `NodeTableRow`, `NodeTableCell` + +> Types disabled by SiYuan (footnotes / ToC / YAML / LinkRef / HeadingID, etc.) are listed in §11 and never appear in a `.sy` — they are intentionally excluded from this catalog. + +--- + +## 5. Block types in detail (with copyable samples) + +### 5.1 Paragraph + +```json +{ "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "...", "updated": "..." }, + "Children": [ { "Type": "NodeText", "Data": "This is a sample paragraph." } ] } +``` + +### 5.2 Heading + +```json +{ "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. +- SiYuan recommends using **level-2 headings at the top of the body**, not level 1. + +### 5.3 Lists (key: distinguish type via `ListData.Typ`) + +> **★ Hard structural constraint of lists:** direct children of `NodeList` can **only** be `NodeListItem` (enforced by Lute's `CanContain`, `ast/node.go:993` `return NodeListItem == nodeType`). Paragraphs, code blocks, sub-lists, or any other block **cannot** be attached directly under `NodeList` — they must be wrapped in a `NodeListItem` first. + +``` +✅ 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): + +```json +{ "Type": "NodeList", "ID": "...", "ListData": {}, + "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeListItem", "ID": "...", + "ListData": { "BulletChar": 42, "Marker": "Kg==" }, + "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "..." }, + "Children": [ { "Type": "NodeText", "Data": "Item one" } ] } + ] } + ] } +``` + +**Ordered list** (`Typ: 1`): + +```json +{ "Type": "NodeListItem", "ID": "...", "Data": "1", + "ListData": { "Typ": 1, "Tight": true, "Start": 1, "Delimiter": 46, "Padding": 3, "Marker": "MQ==", "Num": 1 }, + "Properties": { "id": "..." }, "Children": [ ... ] } +``` + +**Task list** (`Typ: 3`); the first child is a `NodeTaskListItemMarker`: + +```json +{ "Type": "NodeListItem", "ID": "...", + "ListData": { "Typ": 3, "Tight": true, "BulletChar": 45, "Padding": 2, "Checked": true, "Marker": "LQ==", "Num": -1 }, + "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeTaskListItemMarker", "Data": "[X]", "TaskListItemChecked": true }, + { "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "..." }, + "Children": [ { "Type": "NodeText", "Data": "Task one" } ] } + ] } +``` + +### 5.4 `ListData` fields in full (★ easiest to get wrong) + +| Field | Type | Meaning | +|---|---|---| +| `Typ` | int | **List type discriminator:** omitted = unordered, `1` = ordered, `3` = task | +| `Tight` | bool | Tight (no blank lines); optional | +| `BulletChar` | int | Bullet **ASCII codepoint** for unordered/task lists (`42` = `*`, `45` = `-`) | +| `Delimiter` | int | Ordered-list delimiter ASCII codepoint (`46` = `.`) | +| `Start` | int | Ordered-list start number | +| `Num` | int | This item's number; **always `-1` for unordered/task lists** | +| `Padding` | int | Indent padding; optional | +| `Checked` | bool | Whether the task item is checked (aggregated at the list level) | +| `Marker` | string(base64) | The marker text, **base64-encoded**; may include a delimiter (`"MS4="` = `1.`) or not (`"MQ=="` = `1`) | + +> Key distinction: **`BulletChar`/`Delimiter` are int codepoints**; **`Marker` is a base64 string**. Easy to confuse. + +### 5.5 Task marker + +```json +// Checked +{ "Type": "NodeTaskListItemMarker", "Data": "[X]", "TaskListItemChecked": true } +// Unchecked +{ "Type": "NodeTaskListItemMarker", "Data": "[ ]" } +``` + +> The checked-state `Data` is uppercase `[X]`, **not** `[x]`. Unchecked may omit `TaskListItemChecked` (default false). + +### 5.6 Blockquote + +```json +{ "Type": "NodeBlockquote", "ID": "...", "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeBlockquoteMarker", "Data": "> " }, + { "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "..." }, + "Children": [ { "Type": "NodeText", "Data": "Quoted content" } ] } + ] } +``` + +> `NodeBlockquoteMarker.Data` may be `">"` or `"> "` — both are legal. + +### 5.7 Callout (GFM Alert) + +```json +{ "Type": "NodeCallout", "ID": "...", + "CalloutType": "NOTE", "CalloutTitle": "Note", "CalloutIcon": "✏️", + "Properties": { "id": "...", "updated": "..." }, + "Children": [ { "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "..." }, + "Children": [ { "Type": "NodeText", "Data": "Callout content" } ] } ] } +``` + +| `CalloutType` | `CalloutTitle` | `CalloutIcon` | +|---|---|---| +| `NOTE` | `Note` | `✏️` | +| `TIP` | `Tip` | `💡` | +| `IMPORTANT` | `Important` | `❗` | +| `WARNING` | `Warning` | `⚠️` | +| `CAUTION` | `Caution` | `🚨` | + +> `CalloutIcon` is a **literal emoji character**, not base64, not a codepoint. + +### 5.8 Super block (nestable; three-part structure) + +```json +{ "Type": "NodeSuperBlock", "ID": "...", "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeSuperBlockOpenMarker" }, + { "Type": "NodeSuperBlockLayoutMarker", "Data": "col" }, + { "Type": "NodeSuperBlock", "ID": "...", "Properties": { "id": "..." }, "Children": [ ... nested super block, Data "row" ... ] }, + { "Type": "NodeSuperBlockCloseMarker" } + ] } +``` + +> `NodeSuperBlockLayoutMarker.Data` can only be `"row"` (horizontal) or `"col"` (vertical). Super blocks can nest, and it is the only container that can hold any block (including itself). + +### 5.9 Embed block (five-part structure `{{ ... }}`) + +```json +{ "Type": "NodeBlockQueryEmbed", "ID": "...", "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeOpenBrace" }, + { "Type": "NodeOpenBrace" }, + { "Type": "NodeBlockQueryEmbedScript", "Data": "select * from blocks where id='20210428212840-8rqwn5o'" }, + { "Type": "NodeCloseBrace" }, + { "Type": "NodeCloseBrace" } + ] } +``` + +### 5.10 Code block (four-part structure; fenced only) + +```json +{ "Type": "NodeCodeBlock", "ID": "...", "IsFencedCodeBlock": true, + "CodeBlockFenceChar": 96, "CodeBlockFenceLen": 3, + "CodeBlockOpenFence": "YGBg", "CodeBlockInfo": "Z28=", "CodeBlockCloseFence": "YGBg", + "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeCodeBlockFenceOpenMarker", "Data": "```", "CodeBlockFenceLen": 3 }, + { "Type": "NodeCodeBlockFenceInfoMarker", "CodeBlockInfo": "Z28=" }, + { "Type": "NodeCodeBlockCode", "ID": "...", "Data": "package main\n...\n", "Properties": { "id": "..." } }, + { "Type": "NodeCodeBlockFenceCloseMarker", "Data": "```", "CodeBlockFenceLen": 3 } + ] } +``` + +Notes: +- `NodeCodeBlockCode` is **block-level** (has `ID`, `Properties`); content goes in `Data` (raw text, `\n` escaped). +- The surrounding markers are inline (no ID). +- `CodeBlockInfo` is the **base64-encoded language** (`"Z28="` = `go`). The three fence fields (`CodeBlockOpenFence`/`CloseFence` are both `"YGBg"` = ` ``` `) and `CodeBlockFenceChar`/`FenceLen` are **optional** (in the shorthand form they can be omitted, using only `IsFencedCodeBlock: true`). +- SiYuan **does not support indented code blocks** (`SetIndentCodeBlock(false)`); all code blocks are fenced. + +### 5.11 Math block (three-part structure) + +```json +{ "Type": "NodeMathBlock", "ID": "...", "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeMathBlockOpenMarker" }, + { "Type": "NodeMathBlockContent", "ID": "...", "Data": "a^2 + b^2 = c^2", "Properties": { "id": "..." } }, + { "Type": "NodeMathBlockCloseMarker" } + ] } +``` + +### 5.12 HTML / IFrame / Video / Audio blocks (leaf; content in the top-level `Data`) + +```json +{ "Type": "NodeHTMLBlock", "ID": "...", "Data": "
\n...
", "Properties": { "id": "..." } } +{ "Type": "NodeIFrame", "ID": "...", "Data": "", "Properties": { "id": "..." } } +{ "Type": "NodeVideo", "ID": "...", "Data": "", "Properties": { "id": "..." } } +{ "Type": "NodeAudio", "ID": "...", "Data": "", "Properties": { "id": "..." } } +``` + +> These four **have no `Children`**; the HTML content (JSON-escaped) goes directly in the top-level `Data`. + +### 5.13 Table + +```json +{ "Type": "NodeTable", "ID": "...", "TableAligns": [0, 0, 0], + "Properties": { "id": "...", "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" } ] } + ] } + ] } +``` + +- Nesting is fixed: `NodeTable > NodeTableHead/NodeTableRow > NodeTableCell > inline`. +- `TableAligns`: int array of per-column alignment, `0` = default/left. +- `Data` (`thead`/`tr`/`th`/`td`) may be **omitted** in compact files. +- Column widths are stored in `Properties.colgroup` (`|`-separated). + +### 5.14 AttributeView block (database; leaf) + +```json +{ "Type": "NodeAttributeView", "ID": "...", + "Properties": { "custom-sy-av-view": "20251230141609-lcme2fh", "id": "...", "updated": "..." }, + "AttributeViewID": "20251230141609-2kvghrg", + "AttributeViewType": "table" } +``` + +- **Has no `Children`.** +- `AttributeViewID` points to the AV table data (stored in a separate `.json` — **don't** fabricate this ID). +- `AttributeViewType`: `table` / `kanban` / `gallery`, etc. +- `custom-sy-av-view` records the current view ID. + +> AI is advised **not to create new AttributeView blocks**, since the table data is not in the `.sy` — it requires accompanying files. + +### 5.15 Thematic break + +```json +{ "Type": "NodeThematicBreak", "ID": "...", "Properties": { "id": "..." } } +``` + +--- + +## 6. Inline nodes in detail + +### 6.1 `NodeText` (plain text) + +```json +{ "Type": "NodeText", "Data": "plain text" } +``` + +`Data` **may be omitted** (common as a placeholder zero-width space: `{ "Type": "NodeText" }`). + +### 6.2 `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`, etc. `【inferred】` | + +Samples: + +```json +{ "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 text, `"d"` = dynamic embed. +- `TextMarkType` may stack multiple marks separated by spaces, e.g. `"strong em"`. +- `TextMarkTextContent` is not present on every type (`inline-math` lacks it). +- Strikethrough **supports only double-tilde `~~x~~`**, not single-tilde `~x~` (`SetGFMStrikethrough1(false)`). + +### 6.3 Styled inline text (★ must be paired) + +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: + +```json +{ "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. + +### 6.4 `NodeImage` (seven-part structure) + +```json +{ "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": "NodeCloseParen" } +] } +``` + +- The image node itself has `Data` = `"span"`. +- `NodeBang`/`NodeOpenBracket`/`NodeCloseBracket`/`NodeOpenParen`/`NodeCloseParen` markers **may omit** `Data`. +- Only `NodeLinkText` and `NodeLinkDest` carry `Data`. + +--- + +## 7. base64 encoding convention (★ must-read) + +| 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` = `.` | +| `Data` (paragraph text, code content, link, SQL, etc.) | **raw** (not encoded) | `"package main\n..."` | + +> Rule of thumb: **marker fields** like `Marker`/`Fence`/`Info` are base64; **content fields** like `Data`, `TextMarkTextContent`, `TextMarkInlineMathContent` are raw; `BulletChar`/`Delimiter` are int codepoints. + +--- + +## 8. Properties (IAL) in full + +A flat `map[string]string`. + +**Document-level (required):** `id`, `title`, `type` (always `"doc"`), `updated`. Optional: `icon` (emoji codepoint hex, e.g. `"1f4f0"`), `title-img` (CSS). + +**Block-level (required):** `id` (= the node's `ID`), `updated`. Optional: `style` (inline CSS), `fold: "1"` (collapsed), `colgroup` (table column widths), arbitrary `custom-*` custom attributes. + +> The authoritative key is **lowercase** `id`. Some legacy imported files also carry a leftover uppercase `ID` — prefer lowercase. + +--- + +## 9. Container containment cheat sheet + +| 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), wrapped by the three markers | none (most permissive) | +| `NodeDocument` | any non-`NodeListItem` block | `NodeListItem` | + +> These rules are enforced by Lute's `CanContain` (`ast/node.go:988`). Violations cause parse/render anomalies — AI must obey them when generating. + +--- + +## 10. Zero-width space convention + +`.sy` files make heavy use of `​` (U+200B) as a separator placeholder. Around images, inline code, tags, kbd, etc., there is **usually a** `NodeText` with `Data` = `​` on **each side**, to keep rendering and caret behavior correct. AI should follow this convention when generating such content. + +--- + +## 11. Markdown syntax disabled by SiYuan (must not appear in `.sy`) + +SiYuan disables the following syntax via `SetXxx(false)` in `NewLute()` (`kernel/util/lute.go`). The corresponding node types **never** appear in `.sy` files — AI must not generate them: + +| Disabled item | Corresponding node types | Note | +|---|---|---| +| `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 | + +--- + +## 12. AI write checklist + +When generating a `.sy` that SiYuan can load cleanly, verify item by item: + +1. ☐ Root `Type` = `"NodeDocument"`, `Spec` = `"2"`; root `ID` = filename (without `.sy`) and equals `Properties.id` +2. ☐ Root `Properties` contains `id`/`title`/`type:"doc"`/`updated` +3. ☐ Every block has a 22-char `ID`; `Properties.id` = `ID`; `Properties.updated` is a valid 14-digit timestamp +4. ☐ Inline/marker nodes **do not carry** `ID` +5. ☐ Lists are distinguished via `ListData.Typ` (omitted = unordered / `1` = ordered / `3` = task) +6. ☐ `NodeList`'s direct children are **only** `NodeListItem`; nested lists wrap another `NodeList` +7. ☐ `BulletChar`/`Delimiter` are int codepoints; `Marker` is base64 +8. ☐ Task checks use uppercase `[X]`, unchecked `[ ]` +9. ☐ Code block four parts, math block three parts, embed five parts, super block three parts — structure intact +10. ☐ `NodeCodeBlockCode`/`NodeMathBlockContent` are block-level (with ID); surrounding markers are inline (no ID) +11. ☐ base64 fields are encoded; content fields stay raw +12. ☐ Prefer `NodeTextMark` for inline formatting over legacy `NodeStrong`/`NodeEmphasis`/`NodeLink` +13. ☐ A styled `TextMark` must be followed by a paired `NodeKramdownSpanIAL` +14. ☐ HTML/IFrame/Video/Audio/AttributeView are leaves with no `Children` (content in `Data` or type-specific fields) +15. ☐ Do not fabricate `AttributeViewID`/`block-ref` target IDs (they must point to real blocks/AVs) +16. ☐ Do not generate disabled types (footnotes/ToC/YAML/LinkRef/HeadingID, etc. — see §11) + +--- + +## 13. Pitfalls and common mistakes + +| ❌ Wrong | ✅ Correct | +|---|---| +| Assuming every node has `Data` | `Data` may be omitted; marker nodes often lack it | +| Inline node carries `ID` | Inline/marker nodes have no `ID` | +| Using legacy nodes like `NodeStrong`/`NodeLink` | Use `NodeTextMark` + `TextMarkType` | +| `ListData.Typ` only accepts `1` | omitted = unordered, `1` = ordered, `3` = task | +| Treating `BulletChar` as base64 | It's an int ASCII codepoint (`42`) | +| Writing task checks lowercase `[x]` | Uppercase `[X]` | +| Styled `TextMark` without the IAL | Must pair with `NodeKramdownSpanIAL` | +| Adding `Children` to `NodeAttributeView` | It's a leaf — use `AttributeViewID`/`AttributeViewType` | +| Changing `ID` without syncing `Properties.id` | The two must match | +| `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 | +| Generating footnotes/ToC/YAML, etc. | SiYuan disables these; they never appear in `.sy` | + +--- + +## 14. Minimal writable document template + +```json +{ + "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": "." } + ] + } + ] +} +``` + +--- + +## Appendix: verification sources + +- Sample 1: `app/guide/.../20200825162036-4dx365o.sy` (formatting elements — covers nearly all block types) +- Sample 2: `app/guide/.../20200905090211-2vixtlf.sy` (block types — incl. compact lists, AttributeView) +- Node-type constants and serialization logic: `lute/ast/node.go`, `lute/render/json_renderer.go`, `dataparser/sy.go` +- List containment check: `lute/ast/node.go:988` (`CanContain`) +- Disabled-syntax config: `kernel/util/lute.go:51` (`NewLute`) +- Fields marked `【inferred】` (e.g. the sub-fields of `file-annotation-ref`) should be re-verified against a real sample before generation. diff --git a/SY-FORMAT.zh-CN.md b/SY-FORMAT.zh-CN.md new file mode 100644 index 000000000..e21d2ac70 --- /dev/null +++ b/SY-FORMAT.zh-CN.md @@ -0,0 +1,592 @@ +# SiYuan `.sy` 文件 JSON 结构规范 —— AI 读写指南 + +> 版本基准:Spec `2`(当前所有文件)。 +> 核验样本:`20200825162036-4dx365o.sy`(排版元素)、`20200905090211-2vixtlf.sy`(内容块类型)。 +> 本文档所有结论均基于真实样本与 Lute / 思源内核代码核验;标注 `【推断】` 的字段表示未在样本中直接出现,建议生成前再用真实样本核验。 + +## 0. 一句话本质 + +`.sy` = 一个 Lute AST 树序列化成的 JSON。根节点恒为 `NodeDocument`,正文是其 `Children` 数组递归嵌套的节点树。没有外部 schema,全部状态在树里。 + +--- + +## 0.5 何时直接读写 `.sy`(优先级) + +思源提供了 **HTTP API、MCP、CLI** 三条官方路径来修改数据。**默认应优先使用它们**,因为内核会负责 AST 序列化、块 ID 分配,以及两套索引的同步——块树索引(`blocktree.db`,块 ID → 文件路径映射,块引用/面包屑依赖它)和全文搜索索引(`siyuan.db` + FTS5)。直接改盘绕过了这些逻辑,容易导致索引不一致。 + +**仅当官方路径不便时,才直接以 JSON 读写 `.sy`**。适用场景: +- 批量离线迁移(冷初始化工作区、外部数据导入) +- 只读的内容统计、分析、自定义导出/格式转换 +- 修复底层结构问题(遗留文件、非法节点) +- 程序化生成模板/脚手架 + +四条路径的分工: + +| 路径 | 定位 | 修改能力 | +|---|---|---| +| **HTTP API** | 运行时在线操作 | 最全,文档/块的增删改查(`filetree/*`、`block/*`、`transactions`) | +| **MCP** | LLM 工具集 | AI agent 在线操作文档的子集 | +| **CLI** | 批处理 / 运维 | 导入、导出、同步、SQL 等命令行任务 | +| **直接读写 `.sy`** | 本规范覆盖范围 | 离线、批量、底层结构操作 | + +> ⚠️ 直接改盘后,通常需要触发一次"重建索引"才能让搜索/块引用生效。若思源正在运行,更稳妥的是走 HTTP API,由内核负责序列化与索引同步。 + +--- + +## 1. 整体结构 + +```json +{ + "ID": "20200825162036-4dx365o", + "Spec": "2", + "Type": "NodeDocument", + "Properties": { + "icon": "1f4f0", + "id": "20200825162036-4dx365o", + "title": "排版元素", + "type": "doc", + "updated": "20260616224229" + }, + "Children": [ ... ] +} +``` + +| 顶层键 | 必有 | 说明 | +|---|---|---| +| `ID` | ✅ | 文档块 ID,**等于文件名去掉 `.sy`**(不是随机生成) | +| `Spec` | ✅ | 恒 `"2"` | +| `Type` | ✅ | 恒 `"NodeDocument"` | +| `Properties` | ✅ | 文档级 IAL,见 §8 | +| `Children` | ✅ | 正文子块数组,至少含一个块 | + +> ⚠️ 文件路径与根 ID 严格对应:`data//<...>/<根ID>.sy`。改根 ID 等于改文件名,AI 不要随意改根 ID。 + +--- + +## 2. 通用字段语义(每个节点都适用) + +| 字段 | 类型 | 出现条件 | 语义 | +|---|---|---|---| +| `Type` | string | **所有节点必有** | 类型判别字段,如 `"NodeParagraph"` | +| `ID` | string | 仅 block 节点 | 22 字符块 ID;内联/标记节点**不带** | +| `Data` | string | 部分 | 文本/HTML/markdown 原文;**可省略**(不能假设必有) | +| `Properties` | object | 多数 block | IAL,`map[string]string` | +| `Children` | array | 容器/可嵌套节点 | 子节点数组 | +| 类型专属字段 | - | 按类型 | 如 `HeadingLevel`、`ListData`、`TextMarkType`、`AttributeViewID` | + +**核心判别规则**:是否有 `ID` 决定是否是 block。有 `ID` ⇒ block(其 `Properties.id` 必须等于 `ID`);无 `ID` ⇒ 内联/标记节点。 + +--- + +## 3. ID 与时间戳规则 + +- **ID 格式**:`YYYYMMDDHHMMSS-xxxxxxx` = 14 位时间戳 + `-` + 7 位随机 `[a-z0-9]`。例:`20210104091228-ttcj9nm`。 +- **根 ID** 来自文件名,**不**重新生成。 +- **子块 ID** 用上述算法生成。 +- `Properties.updated` 用 14 位时间戳,语义"最后更新时间"。 +- 改任何 block 的 `ID`,必须**同步** `Properties.id`。`Properties.updated` 建议同步刷新为当前时间。 + +--- + +## 4. 节点类型目录 + +### Block 节点(有 ID) + +**叶子 block**:`NodeParagraph`、`NodeHeading`、`NodeThematicBreak`、`NodeHTMLBlock`、`NodeCodeBlock`、`NodeMathBlock`、`NodeTable`、`NodeBlockQueryEmbed`、`NodeAttributeView`、`NodeIFrame`、`NodeVideo`、`NodeAudio`、`NodeWidget`、`NodeCustomBlock`、`NodeGitConflict` + +**容器 block**:`NodeList`、`NodeListItem`、`NodeBlockquote`、`NodeCallout`、`NodeSuperBlock` + +**块级内容子节点**(位于块内部,也带 ID):`NodeCodeBlockCode`、`NodeMathBlockContent` + +### 内联/标记节点(无 ID) + +`NodeText`、`NodeTextMark`、`NodeImage`、`NodeKramdownSpanIAL`、`NodeHeadingC8hMarker`、`NodeBlockquoteMarker`、`NodeTaskListItemMarker`、`NodeBang`、`NodeOpenBracket`、`NodeCloseBracket`、`NodeOpenParen`、`NodeCloseParen`、`NodeLinkText`、`NodeLinkDest`、`NodeCodeBlockFenceOpenMarker`、`NodeCodeBlockFenceInfoMarker`、`NodeCodeBlockFenceCloseMarker`、`NodeMathBlockOpenMarker`、`NodeMathBlockCloseMarker`、`NodeSuperBlockOpenMarker`、`NodeSuperBlockLayoutMarker`、`NodeSuperBlockCloseMarker`、`NodeOpenBrace`、`NodeCloseBrace`、`NodeBlockQueryEmbedScript`、`NodeTableHead`、`NodeTableRow`、`NodeTableCell` + +> 思源禁用的类型(脚注 / ToC / YAML / LinkRef / HeadingID 等)见 §11,不应出现在 `.sy` 中,故未列入目录。 + +--- + +## 5. 各 Block 类型详解 + 可复制样例 + +### 5.1 段落 + +```json +{ "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "...", "updated": "..." }, + "Children": [ { "Type": "NodeText", "Data": "这里是一个示例段落。" } ] } +``` + +### 5.2 标题 + +```json +{ "Type": "NodeHeading", "ID": "...", "HeadingLevel": 2, + "Properties": { "id": "...", "updated": "..." }, + "Children": [ { "Type": "NodeText", "Data": "标题块" } ] } +``` + +- `HeadingLevel` 取值 `1`–`6`。 +- `NodeHeadingC8hMarker`(`Data` 如 `"## "`)**可选**,有无都合法。建议生成时**省略**它,更简洁。 +- SiYuan 建议**正文顶层用二级标题**,不要用一级。 + +### 5.3 列表(关键:用 `ListData.Typ` 区分类型) + +> **★ 列表的硬性结构约束**:`NodeList` 的直接子节点**只能**是 `NodeListItem`(Lute 在 `CanContain` 中强制校验,`ast/node.go:993` `return NodeListItem == nodeType`)。段落、代码块、子列表等任何其他块都**不能**直接挂在 `NodeList` 下,必须先包一层 `NodeListItem`。 + +``` +✅ 正确 ❌ 错误 +NodeList NodeList +└─ NodeListItem ├─ NodeParagraph ← 非法 + └─ NodeParagraph └─ NodeCodeBlock ← 非法 +``` + +**嵌套列表**的正确写法是再套一层 `NodeList`(`NodeListItem` 走默认 `CanContain` 分支,不能直接含另一个 `NodeListItem`): + +``` +✅ 正确 ❌ 错误 +NodeList NodeList +└─ NodeListItem └─ NodeListItem + ├─ NodeParagraph ├─ NodeParagraph + └─ NodeList ← 子列表 └─ NodeListItem ← 非法 + └─ NodeListItem + └─ NodeParagraph +``` + +**无序列表**(`Typ` 缺省): + +```json +{ "Type": "NodeList", "ID": "...", "ListData": {}, + "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeListItem", "ID": "...", + "ListData": { "BulletChar": 42, "Marker": "Kg==" }, + "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "..." }, + "Children": [ { "Type": "NodeText", "Data": "列表项一" } ] } + ] } + ] } +``` + +**有序列表**(`Typ: 1`): + +```json +{ "Type": "NodeListItem", "ID": "...", "Data": "1", + "ListData": { "Typ": 1, "Tight": true, "Start": 1, "Delimiter": 46, "Padding": 3, "Marker": "MQ==", "Num": 1 }, + "Properties": { "id": "..." }, "Children": [ ... ] } +``` + +**任务列表**(`Typ: 3`),子节点首项是 `NodeTaskListItemMarker`: + +```json +{ "Type": "NodeListItem", "ID": "...", + "ListData": { "Typ": 3, "Tight": true, "BulletChar": 45, "Padding": 2, "Checked": true, "Marker": "LQ==", "Num": -1 }, + "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeTaskListItemMarker", "Data": "[X]", "TaskListItemChecked": true }, + { "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "..." }, + "Children": [ { "Type": "NodeText", "Data": "待办一" } ] } + ] } +``` + +### 5.4 `ListData` 字段全解(★ 最易踩坑) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `Typ` | int | **列表类型判别**:缺省=无序,`1`=有序,`3`=任务 | +| `Tight` | bool | 紧凑态(无空行);可选 | +| `BulletChar` | int | 无序/任务列表的项目符号 **ASCII 码点**(`42`=`*`,`45`=`-`) | +| `Delimiter` | int | 有序列表分隔符 ASCII 码点(`46`=`.`) | +| `Start` | int | 有序列表起始编号 | +| `Num` | int | 该项序号;**无序/任务列表恒为 `-1`** | +| `Padding` | int | 缩进填充数;可选 | +| `Checked` | bool | 任务列表项是否勾选(列表级聚合) | +| `Marker` | string(base64) | 标记符原文的 **base64**;可能含分隔符(`"MS4="`=`1.`)也可能不含(`"MQ=="`=`1`) | + +> 关键区分:**`BulletChar`/`Delimiter` 是 int(ASCII 码点)**;**`Marker` 是 base64 字符串**。二者容易混淆。 + +### 5.5 任务标记 + +```json +// 已勾选 +{ "Type": "NodeTaskListItemMarker", "Data": "[X]", "TaskListItemChecked": true } +// 未勾选 +{ "Type": "NodeTaskListItemMarker", "Data": "[ ]" } +``` + +> 勾选态 `Data` 是大写 `[X]`,**不是** `[x]`。未勾选可省略 `TaskListItemChecked`(缺省即 false)。 + +### 5.6 引述块 + +```json +{ "Type": "NodeBlockquote", "ID": "...", "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeBlockquoteMarker", "Data": "> " }, + { "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "..." }, + "Children": [ { "Type": "NodeText", "Data": "引述内容" } ] } + ] } +``` + +> `NodeBlockquoteMarker.Data` 可以是 `">"` 或 `"> "`,都合法。 + +### 5.7 提示块(Callout / GFM Alert) + +```json +{ "Type": "NodeCallout", "ID": "...", + "CalloutType": "NOTE", "CalloutTitle": "Note", "CalloutIcon": "✏️", + "Properties": { "id": "...", "updated": "..." }, + "Children": [ { "Type": "NodeParagraph", "ID": "...", "Properties": { "id": "..." }, + "Children": [ { "Type": "NodeText", "Data": "提示内容" } ] } ] } +``` + +| `CalloutType` | `CalloutTitle` | `CalloutIcon` | +|---|---|---| +| `NOTE` | `Note` | `✏️` | +| `TIP` | `Tip` | `💡` | +| `IMPORTANT` | `Important` | `❗` | +| `WARNING` | `Warning` | `⚠️` | +| `CAUTION` | `Caution` | `🚨` | + +> `CalloutIcon` 是**直接 emoji 字符**,不是 base64、不是码点。 + +### 5.8 超级块(可嵌套,三段结构) + +```json +{ "Type": "NodeSuperBlock", "ID": "...", "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeSuperBlockOpenMarker" }, + { "Type": "NodeSuperBlockLayoutMarker", "Data": "col" }, + { "Type": "NodeSuperBlock", "ID": "...", "Properties": { "id": "..." }, "Children": [ ... 内嵌超级块,Data 为 "row" ... ] }, + { "Type": "NodeSuperBlockCloseMarker" } + ] } +``` + +> `NodeSuperBlockLayoutMarker.Data` 只能是 `"row"`(横向)或 `"col"`(纵向)。超级块可嵌套,且是唯一能容纳任意块(含嵌套自身)的容器。 + +### 5.9 嵌入块(五段结构 `{{ ... }}`) + +```json +{ "Type": "NodeBlockQueryEmbed", "ID": "...", "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeOpenBrace" }, + { "Type": "NodeOpenBrace" }, + { "Type": "NodeBlockQueryEmbedScript", "Data": "select * from blocks where id='20210428212840-8rqwn5o'" }, + { "Type": "NodeCloseBrace" }, + { "Type": "NodeCloseBrace" } + ] } +``` + +### 5.10 代码块(四段结构,仅围栏式) + +```json +{ "Type": "NodeCodeBlock", "ID": "...", "IsFencedCodeBlock": true, + "CodeBlockFenceChar": 96, "CodeBlockFenceLen": 3, + "CodeBlockOpenFence": "YGBg", "CodeBlockInfo": "Z28=", "CodeBlockCloseFence": "YGBg", + "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeCodeBlockFenceOpenMarker", "Data": "```", "CodeBlockFenceLen": 3 }, + { "Type": "NodeCodeBlockFenceInfoMarker", "CodeBlockInfo": "Z28=" }, + { "Type": "NodeCodeBlockCode", "ID": "...", "Data": "package main\n...\n", "Properties": { "id": "..." } }, + { "Type": "NodeCodeBlockFenceCloseMarker", "Data": "```", "CodeBlockFenceLen": 3 } + ] } +``` + +要点: +- `NodeCodeBlockCode` 是**块级**(有 `ID`、`Properties`),内容放 `Data`(原始文本,`\n` 转义)。 +- 外围 marker 是内联(无 ID)。 +- `CodeBlockInfo` 是**语言名的 base64**(`"Z28="`=`go`)。三个 fence 字段(`CodeBlockOpenFence`/`CloseFence` 都是 `"YGBg"`=` ``` `)和 `CodeBlockFenceChar`/`FenceLen` 是**可选**的(简写时可省略,只用 `IsFencedCodeBlock:true`)。 +- 思源**不支持缩进式代码块**(`SetIndentCodeBlock(false)`),所有代码块都是围栏式。 + +### 5.11 数学块(三段结构) + +```json +{ "Type": "NodeMathBlock", "ID": "...", "Properties": { "id": "..." }, + "Children": [ + { "Type": "NodeMathBlockOpenMarker" }, + { "Type": "NodeMathBlockContent", "ID": "...", "Data": "a^2 + b^2 = c^2", "Properties": { "id": "..." } }, + { "Type": "NodeMathBlockCloseMarker" } + ] } +``` + +### 5.12 HTML / IFrame / Video / Audio 块(叶子,内容在顶层 `Data`) + +```json +{ "Type": "NodeHTMLBlock", "ID": "...", "Data": "
\n...
", "Properties": { "id": "..." } } +{ "Type": "NodeIFrame", "ID": "...", "Data": "", "Properties": { "id": "..." } } +{ "Type": "NodeVideo", "ID": "...", "Data": "", "Properties": { "id": "..." } } +{ "Type": "NodeAudio", "ID": "...", "Data": "", "Properties": { "id": "..." } } +``` + +> 这四种**没有 Children**,HTML 内容(JSON 转义后)直接放顶层 `Data`。 + +### 5.13 表格 + +```json +{ "Type": "NodeTable", "ID": "...", "TableAligns": [0, 0, 0], + "Properties": { "id": "...", "colgroup": "||" }, + "Children": [ + { "Type": "NodeTableHead", "Data": "thead", "Children": [ + { "Type": "NodeTableRow", "Data": "tr", "Children": [ + { "Type": "NodeTableCell", "Data": "th", "Children": [ { "Type": "NodeText", "Data": "表头" } ] } + ] } + ] }, + { "Type": "NodeTableRow", "Data": "tr", "Children": [ + { "Type": "NodeTableCell", "Data": "td", "Children": [ { "Type": "NodeText", "Data": "单元格" } ] } + ] } + ] } +``` + +- 嵌套层级固定:`NodeTable > NodeTableHead/NodeTableRow > NodeTableCell > 内联`。 +- `TableAligns`:每列对齐值的 int 数组,`0`=默认/左。 +- `Data`(`thead`/`tr`/`th`/`td`)在紧凑文件里**可省略**。 +- 列宽用 `Properties.colgroup`(`|` 分隔)记录。 + +### 5.14 数据库块(AttributeView,叶子) + +```json +{ "Type": "NodeAttributeView", "ID": "...", + "Properties": { "custom-sy-av-view": "20251230141609-lcme2fh", "id": "...", "updated": "..." }, + "AttributeViewID": "20251230141609-2kvghrg", + "AttributeViewType": "table" } +``` + +- **没有 Children**。 +- `AttributeViewID` 指向 AV 表数据(存在单独的 `.json`,**不要**由 AI 凭空构造 ID)。 +- `AttributeViewType`:`table` / `kanban` / `gallery` 等。 +- `custom-sy-av-view` 记录当前视图 ID。 + +> 建议 AI **不要创建新的 AttributeView 块**,因为表数据不在 `.sy` 里,需配套文件。 + +### 5.15 分隔线 + +```json +{ "Type": "NodeThematicBreak", "ID": "...", "Properties": { "id": "..." } } +``` + +--- + +## 6. 内联节点详解 + +### 6.1 `NodeText`(纯文本) + +```json +{ "Type": "NodeText", "Data": "普通文本" } +``` + +`Data` **可省略**(用作占位零宽空格时常见 `{ "Type": "NodeText" }`)。 + +### 6.2 `NodeTextMark`(现代内联格式的统一载体) + +`.sy` 里加粗/斜体/链接/行内代码/块引用等**几乎全部**用 `NodeTextMark`,而**不是** `NodeStrong`/`NodeEmphasis`/`NodeLink`。`TextMarkType` 决定类型。 + +| `TextMarkType` | 含义 | 必带字段 | +|---|---|---| +| `text` | 纯文本 | `TextMarkTextContent` | +| `strong` | 加粗 | `TextMarkTextContent` | +| `em` | 倾斜 | `TextMarkTextContent` | +| `u` | 下划线 | `TextMarkTextContent` | +| `s` | 删除线(双波浪 `~~`) | `TextMarkTextContent` | +| `mark` | 高亮 | `TextMarkTextContent` | +| `sup` / `sub` | 上/下标 | `TextMarkTextContent` | +| `kbd` | 键盘键 | `TextMarkTextContent` | +| `code` | 行内代码 | `TextMarkTextContent` | +| `tag` | 标签 `#tag#` | `TextMarkTextContent` | +| `a` | 超链接 | `TextMarkAHref`、`TextMarkTextContent`(可选 `TextMarkATitle`) | +| `block-ref` | 块引用 | `TextMarkBlockRefID`、`TextMarkBlockRefSubtype`、`TextMarkTextContent` | +| `inline-math` | 行内公式 | `TextMarkInlineMathContent`(**无** `TextMarkTextContent`) | +| `inline-memo` | 行级备注 | `TextMarkInlineMemoContent`、`TextMarkTextContent` | +| `file-annotation-ref` | 文件注释引用 | `TextMarkFileAnnotationRefID` 等 `【推断】` | + +样例: + +```json +{ "Type": "NodeTextMark", "TextMarkType": "a", "TextMarkAHref": "https://ld246.com", "TextMarkTextContent": "超链接" } +{ "Type": "NodeTextMark", "TextMarkType": "block-ref", "TextMarkBlockRefID": "20200812220555-lj3enxa", "TextMarkBlockRefSubtype": "s", "TextMarkTextContent": "块引用" } +{ "Type": "NodeTextMark", "TextMarkType": "inline-math", "TextMarkInlineMathContent": "a^2 + b^2 = c^2" } +{ "Type": "NodeTextMark", "TextMarkType": "inline-memo", "TextMarkInlineMemoContent": "这是一个行级备注", "TextMarkTextContent": "备注" } +``` + +- `TextMarkBlockRefSubtype`:`"s"`=静态文本,`"d"`=动态嵌入。 +- `TextMarkType` 可空格叠加多标记,如 `"strong em"`。 +- `TextMarkTextContent` 不是所有类型都有(`inline-math` 就没有)。 +- 删除线**仅支持双波浪 `~~x~~`**,不支持单波浪 `~x~`(`SetGFMStrikethrough1(false)`)。 + +### 6.3 带样式的内联文本(★ 必须成对) + +带颜色/特效的 `NodeTextMark`(带 `Properties.style`)**后面必须紧跟一个** `NodeKramdownSpanIAL`,且二者 style 文本一致: + +```json +{ "Type": "NodeTextMark", "Properties": { "style": "color: var(--b3-font-color1); background-color: var(--b3-font-background1);" }, + "TextMarkType": "strong", "TextMarkTextContent": "颜色 1" }, +{ "Type": "NodeKramdownSpanIAL", "Data": "{: style=\"color: var(--b3-font-color1); background-color: var(--b3-font-background1);\"}" } +``` + +> AI 生成带样式的内联文本时,这两节点必须成对出现,否则 kramdown 往返会丢样式。 + +### 6.4 `NodeImage`(七段子结构) + +```json +{ "Type": "NodeImage", "Data": "span", "Children": [ + { "Type": "NodeBang" }, + { "Type": "NodeOpenBracket" }, + { "Type": "NodeLinkText", "Data": "alt 文本" }, + { "Type": "NodeCloseBracket" }, + { "Type": "NodeOpenParen" }, + { "Type": "NodeLinkDest", "Data": "assets/image-2021.png" }, + { "Type": "NodeCloseParen" } +] } +``` + +- 图片节点本身 `Data` 是 `"span"`。 +- `NodeBang`/`NodeOpenBracket`/`NodeCloseBracket`/`NodeOpenParen`/`NodeCloseParen` 这些 marker 的 `Data` **可省略**。 +- 只有 `NodeLinkText`、`NodeLinkDest` 带 `Data`。 + +--- + +## 7. base64 编码约定(★ 必读) + +| 字段 | 编码 | 例 | +|---|---|---| +| `ListData.Marker` | base64 | `Kg==`=`*`,`MS4=`=`1.`,`MQ==`=`1` | +| `CodeBlockInfo` | base64 | `Z28=`=`go`,`amF2YQ==`=`java` | +| `CodeBlockOpenFence`/`CloseFence` | base64 | `YGBg`=` ``` ` | +| `ListData.BulletChar`/`Delimiter` | **int ASCII 码点**(**不是** base64) | `42`=`*`,`46`=`.` | +| `Data`(段落文本、代码内容、链接、SQL 等) | **原文**(不编码) | `"package main\n..."` | + +> 判别规则:`Marker`/`Fence`/`Info` 这类**标记符字段**是 base64;`Data`、`TextMarkTextContent`、`TextMarkInlineMathContent` 等内容字段是原文;`BulletChar`/`Delimiter` 是 int 码点。 + +--- + +## 8. Properties(IAL)全解 + +扁平 `map[string]string`。 + +**文档级必有**:`id`、`title`、`type`(恒 `"doc"`)、`updated`。可选:`icon`(emoji 码点十六进制,如 `"1f4f0"`)、`title-img`(CSS)。 + +**Block 级必有**:`id`(= 节点 `ID`)、`updated`。可选:`style`(行内 CSS)、`fold: "1"`(折叠态)、`colgroup`(表格列宽)、任意 `custom-*` 自定义属性。 + +> 权威键是**小写** `id`。某些旧导入文件里同时有遗留的大写 `ID`,以小写为准。 + +--- + +## 9. 容器容纳规则速查 + +| 容器 | 可含 | 不可含 | +|---|---|---| +| `NodeList` | **仅** `NodeListItem` | 任何其他块(段落/代码块/子列表都必须先套 `NodeListItem`) | +| `NodeListItem` | 任意非 `NodeListItem` 块(段落/代码块/子 `NodeList`/超级块…) | `NodeListItem`(嵌套要再套 `NodeList`) | +| `NodeBlockquote` | 任意非 `NodeListItem` 块 + 一个 `NodeBlockquoteMarker` | `NodeListItem` | +| `NodeCallout` | 任意非 `NodeListItem` 块 | `NodeListItem` | +| `NodeSuperBlock` | **任意块**(含嵌套超级块),三段 marker 包裹 | 无(最宽松) | +| `NodeDocument` | 任意非 `NodeListItem` 块 | `NodeListItem` | + +> 这些规则由 Lute 的 `CanContain`(`ast/node.go:988`)强制校验,违反会导致解析/渲染异常,AI 生成时必须遵守。 + +--- + +## 10. 零宽空格约定 + +`.sy` 大量使用 `​`(U+200B)作分隔占位。图片、行内代码、标签、kbd 等内联元素**两侧通常各有一个** `Data` 为 `​` 的 `NodeText`,保证渲染与光标行为。AI 生成这类内容时建议遵循该约定。 + +--- + +## 11. 思源禁用的 Markdown 语法(不应出现在 `.sy` 中) + +思源在 `kernel/util/lute.go` 的 `NewLute()` 中通过 `SetXxx(false)` 禁用了以下语法,对应节点类型**永远不会**出现在 `.sy` 文件里,AI 不要生成: + +| 禁用项 | 对应节点类型 | 说明 | +|---|---|---| +| `SetFootnotes(false)` | `NodeFootnotesDefBlock`/`NodeFootnotesDef`/`NodeFootnotesRef` | 脚注,全禁 | +| `SetToC(false)` | `NodeToC` | `[toc]` 目录 | +| `SetIndentCodeBlock(false)` | 缩进式代码块 | 仅支持围栏代码块 | +| `SetHeadingID(false)` | `NodeHeadingID` | 自定义标题 ID `{#id}` | +| `SetSetext(false)` | Setext 标题(`===`/`---` 下划线式) | 仅支持 ATX 风格 `#` | +| `SetYamlFrontMatter(false)` | `NodeYamlFrontMatter` | YAML 前置元数据 | +| `SetLinkRef(false)` | `NodeLinkRefDef`/`NodeLinkRefDefBlock` | 链接引用定义 | +| `SetGFMStrikethrough1(false)` | 单波浪线删除线 `~x~` | 仅支持双波浪 `~~x~~` | + +--- + +## 12. AI 写入检查清单 + +生成一份能被 SiYuan 正常加载的 `.sy`,逐条核对: + +1. ☐ 根 `Type`=`"NodeDocument"`、`Spec`=`"2"`,根 `ID`=文件名(去 `.sy`)且 = `Properties.id` +2. ☐ 根 `Properties` 含 `id`/`title`/`type:"doc"`/`updated` +3. ☐ 每个 block 有 22 字符 `ID`,且 `Properties.id` = `ID`,`Properties.updated` 为合法 14 位时间戳 +4. ☐ 内联/标记节点**不带** `ID` +5. ☐ 列表用 `ListData.Typ` 区分(缺省=无序/`1`=有序/`3`=任务) +6. ☐ `NodeList` 直接子节点**只能是** `NodeListItem`;嵌套列表要再套一层 `NodeList` +7. ☐ `BulletChar`/`Delimiter` 用 int 码点;`Marker` 用 base64 +8. ☐ 任务勾选用 `[X]`(大写),未勾选 `[ ]` +9. ☐ 代码块四段、数学块三段、嵌入块五段、超级块三段——结构完整 +10. ☐ `NodeCodeBlockCode`/`NodeMathBlockContent` 是块级(带 ID);外围 marker 是内联(无 ID) +11. ☐ base64 字段已编码;内容字段保持原文 +12. ☐ 内联格式优先用 `NodeTextMark`,不用 `NodeStrong`/`NodeEmphasis`/`NodeLink` +13. ☐ 带样式的 TextMark 后必须跟配对的 `NodeKramdownSpanIAL` +14. ☐ HTML/IFrame/Video/Audio/AttributeView 是叶子,无 Children(内容在 `Data` 或专属字段) +15. ☐ 不要凭空构造 `AttributeViewID`/`block-ref` 的目标 ID(须指向真实存在的块/AV) +16. ☐ 不要生成思源禁用的类型(脚注/ToC/YAML/LinkRef/HeadingID 等,见 §11) + +--- + +## 13. 禁忌与常见错误 + +| ❌ 错误 | ✅ 正确 | +|---|---| +| 假设所有节点都有 `Data` | `Data` 可省略,marker 节点常无 `Data` | +| 内联节点带 `ID` | 内联/标记节点不带 `ID` | +| 用 `NodeStrong`/`NodeLink` 等旧式节点 | 用 `NodeTextMark` + `TextMarkType` | +| `ListData.Typ` 只认 `1` | `Typ` 缺省=无序,`1`=有序,`3`=任务 | +| 把 `BulletChar` 当 base64 | 它是 int ASCII 码点(`42`) | +| 任务勾选写 `[x]` 小写 | 大写 `[X]` | +| 带样式 TextMark 不配 IAL | 必须配 `NodeKramdownSpanIAL` | +| 给 `NodeAttributeView` 加 Children | 它是叶子,用 `AttributeViewID`/`AttributeViewType` | +| 改 `ID` 不同步 `Properties.id` | 二者必须一致 | +| `inline-math` 带 `TextMarkTextContent` | 它只有 `TextMarkInlineMathContent` | +| 凭空造 block-ref / AV 的目标 ID | 目标必须真实存在 | +| 把段落直接挂到 `NodeList` 下 | `NodeList` 只能含 `NodeListItem`,必须先包一层 | +| 生成脚注/ToC/YAML 等节点 | 思源禁用这些语法,不会出现在 `.sy` 中 | + +--- + +## 14. 最小可写文档模板 + +```json +{ + "ID": "20260628120000-abc1234", + "Spec": "2", + "Type": "NodeDocument", + "Properties": { + "id": "20260628120000-abc1234", + "title": "新文档", + "type": "doc", + "updated": "20260628120000" + }, + "Children": [ + { + "Type": "NodeHeading", "ID": "20260628120001-def5678", "HeadingLevel": 2, + "Properties": { "id": "20260628120001-def5678", "updated": "20260628120001" }, + "Children": [ { "Type": "NodeText", "Data": "标题" } ] + }, + { + "Type": "NodeParagraph", "ID": "20260628120002-ghi9012", + "Properties": { "id": "20260628120002-ghi9012", "updated": "20260628120002" }, + "Children": [ + { "Type": "NodeText", "Data": "正文含" }, + { "Type": "NodeTextMark", "TextMarkType": "strong", "TextMarkTextContent": "加粗" }, + { "Type": "NodeText", "Data": "。" } + ] + } + ] +} +``` + +--- + +## 附:核验来源 + +- 样本 1:`app/guide/.../20200825162036-4dx365o.sy`(排版元素,覆盖几乎所有块类型) +- 样本 2:`app/guide/.../20200905090211-2vixtlf.sy`(内容块类型,含紧凑列表、AttributeView) +- 节点类型常量与序列化逻辑:`lute/ast/node.go`、`lute/render/json_renderer.go`、`dataparser/sy.go` +- 列表容纳校验:`lute/ast/node.go:988`(`CanContain`) +- 思源禁用语法配置:`kernel/util/lute.go:51`(`NewLute`) +- 标注 `【推断】` 的字段(如 `file-annotation-ref` 的子字段)建议在生成前用真实样本再核验一次。