From 6c643a55b15da392f6e7bdba5369846084872845 Mon Sep 17 00:00:00 2001 From: eunwoo song Date: Mon, 7 Sep 2026 05:29:22 +0900 Subject: [PATCH 1/3] fix: preserve markdown block separators --- docs/specifications/markdown-transcoding.md | 1 + src/lib/data/markdown-transcode.test.ts | 9 +++++++++ src/lib/data/markdown-transcode.ts | 18 ++++++++++++++++++ src/lib/services/services.test.ts | 10 ++++++++++ 4 files changed, 38 insertions(+) diff --git a/docs/specifications/markdown-transcoding.md b/docs/specifications/markdown-transcoding.md index 275e9f9..b717a16 100644 --- a/docs/specifications/markdown-transcoding.md +++ b/docs/specifications/markdown-transcoding.md @@ -9,6 +9,7 @@ Per the PRD: CommonMark + GFM (tables, task-list checkboxes, strikethrough) as t - **Read path** (`Y.Text` → Markdown): walk the `Y.Text`'s formatted ranges, emit standard Markdown syntax for each mark (`**bold**`, `` `code` ``, `[text](url)`), plus `@mention` for a `mention` mark and `[[Title]]` for a `link` mark whose href uses an internal `record:` scheme — i.e. an in-block wiki-link to another Document/Collection, resolved to that target's title at read time. - **Write path** (Markdown → `Y.Text`): parse with a standard CommonMark+GFM parser (e.g., `remark` — pure JS, no framework coupling, fits a bespoke SvelteKit-side build), walk the resulting AST, apply `Y.Text.format()` calls for each inline mark, and resolve `@mention`/`[[...]]` tokens against the workspace's actor/record indices before writing. +- **Block separators on the write path:** when one `markdown` value contains adjacent block-level nodes, preserve their boundary as a blank-line (`\n\n`) run in the block's flat `RichText`. This keeps paragraphs from concatenating while allowing the ordinary read path to emit the same Markdown boundary. - **Not the same thing as a `relation`-typed Collection property.** `[[Title]]` above is block-content markup — a wiki-link inside a paragraph's `Y.Text` — but once written it stores the resolved target's ID (`record:`, not the title) as the run's `link` mark; the title shown is re-resolved from that ID every time the block is read (via `resolveInternalLinkTarget`, see `internal-links.md`), so an edited target title does **not** break the link, and a deleted target renders as an explicit `[[Deleted page]]` rather than falling back to the stale cached title. Title matching only happens on the way _in_: parsing typed/pasted Markdown containing `[[Title]]` with no ID yet, where the title is looked up against the workspace's Documents/Collections to find the ID to store (first match wins on duplicate titles — this is the only place title ambiguity matters). A `relation`-typed property (`data-model.md` §1) is a different boundary entirely: it's a plain array of record IDs stored directly on `WorkspaceRecord.properties`, set and read as-is by `write_record`'s `properties` argument and `query_collection` — it is never transcoded through `Y.Text` or Markdown at all, so title matching (and its duplicate-title ambiguity) never applies to it in either direction. - **`page_link` blocks are not part of this inline encoding.** A page_link's target lives on the block record itself (`referencedRecordId`), not inside its `Y.Text`. The read path (`get_document`) still renders it as `[[Target Title]]` for display — or `[[Deleted page]]` plus an explicit `linkBroken: true` field once the target is deleted, see `internal-links.md` — re-resolving the title from that ID the same way an inline wiki-link does. There is still no path that parses `[[Title]]` Markdown text back into `referencedRecordId` — title-based resolution deliberately never applies to `page_link` (`internal-links.md` §4) — but a page_link's target can now be set directly, by ID, through `create_record`'s and `write_record`'s `referencedRecordId` field (`mcp-tools.md`), not just from the editor UI's document picker. - **A styled `callout` block's markdown is also not part of this inline encoding** (issue #42) — same "block-level concern, not `Y.Text` content" split as `page_link` above. `renderRecordMarkdown` (`src/lib/mcp/document-projection.ts`) prefixes a preset-styled callout's already-transcoded inline content with a GitHub-alert-style marker line — `> [!NOTE]`, `> [!TIP]`, `> [!CAUTION]`, or `> [!DANGER]` — GitHub's own nearest convention, reused for the keyword only: GitHub itself defines NOTE/TIP/IMPORTANT/WARNING/CAUTION, not this repo's four (`data-model.md`'s `CalloutPreset`), and there is no CommonMark blockquote parser anywhere in this codebase to begin with (this module is scoped to inline marks, not block-level Markdown at all). This is therefore **read-direction only** — a custom-styled callout (arbitrary icon+color, no alert-syntax equivalent) and an unstyled one both still render as plain inline content, unchanged from before #42, and there is no write-side parsing that turns an incoming `> [!X]` line back into `calloutStyle`: like `checked`/`collapsed` (`data-model.md` §1), a callout's style has no MCP write path at all (`create_record`/`write_record` don't accept it) — it's UI-only, set through the picker in `rich-text-toolbar.md` §7. `get_document` does expose `calloutStyle` read-only, the same way it already exposes `checked`/`collapsed`. diff --git a/src/lib/data/markdown-transcode.test.ts b/src/lib/data/markdown-transcode.test.ts index 0b8a433..e6e0e6d 100644 --- a/src/lib/data/markdown-transcode.test.ts +++ b/src/lib/data/markdown-transcode.test.ts @@ -24,6 +24,15 @@ describe('markdown transcoding', () => { expect(markdownToRichText(doc, backToMarkdown)).toEqual(richText); }); + it('preserves blank lines between adjacent block-level nodes', () => { + const doc = new Y.Doc(); + const markdown = 'Paragraph one.\n\nParagraph two.'; + const richText = markdownToRichText(doc, markdown); + + expect(richText.runs.map((run) => run.text).join('')).toBe(markdown); + expect(richTextToMarkdown(doc, richText)).toBe('Paragraph one\\.\n\nParagraph two\\.'); + }); + it('parses @mention into a mention-marked run', () => { const doc = new Y.Doc(); const richText = markdownToRichText(doc, 'ping @local please'); diff --git a/src/lib/data/markdown-transcode.ts b/src/lib/data/markdown-transcode.ts index e185881..64324b1 100644 --- a/src/lib/data/markdown-transcode.ts +++ b/src/lib/data/markdown-transcode.ts @@ -104,6 +104,9 @@ export function markdownToRichText(doc: Y.Doc, markdown: string): RichText { function collectRuns(doc: Y.Doc, node: MdastNode, marks: TextMarks, runs: MutableRun[]): void { switch (node.type) { + case 'root': + collectRootRuns(doc, node.children ?? [], marks, runs); + return; case 'text': splitSpecialTokens(doc, node.value ?? '', marks, runs); return; @@ -129,6 +132,21 @@ function collectRuns(doc: Y.Doc, node: MdastNode, marks: TextMarks, runs: Mutabl } } +function collectRootRuns( + doc: Y.Doc, + children: MdastNode[], + marks: TextMarks, + runs: MutableRun[] +): void { + for (const child of children) { + const childRuns: MutableRun[] = []; + collectRuns(doc, child, marks, childRuns); + if (childRuns.length === 0) continue; + if (runs.length > 0) runs.push({ text: '\n\n', marks }); + runs.push(...childRuns); + } +} + function splitSpecialTokens(doc: Y.Doc, text: string, marks: TextMarks, runs: MutableRun[]): void { let lastIndex = 0; for (const match of text.matchAll(SPECIAL_TOKEN)) { diff --git a/src/lib/services/services.test.ts b/src/lib/services/services.test.ts index 326778f..14388ee 100644 --- a/src/lib/services/services.test.ts +++ b/src/lib/services/services.test.ts @@ -188,6 +188,16 @@ describe('service layer: centralized business rules & side effects', () => { ); }); + it('preserves adjacent Markdown paragraphs written to one block', () => { + const document = createDocument(human, { title: 'Paragraph boundaries' }); + const block = createRecord(human, { parentId: document.id, blockType: 'paragraph' }); + + writeRecord(human, block.id, { markdown: 'Paragraph one.\n\nParagraph two.' }); + + const content = servicesGetDocument(human, document.id)?.records[0].content; + expect(content?.runs.map((run) => run.text).join('')).toBe('Paragraph one.\n\nParagraph two.'); + }); + it('manages collections, persists collection grants, and queries rows', () => { const col = createCollection(human, { title: 'Sprint Backlog', From bda72ac6f2fd6ccb124e1b6890436dc30bf582d3 Mon Sep 17 00:00:00 2001 From: eunwoo song Date: Sat, 12 Sep 2026 13:28:40 +0900 Subject: [PATCH 2/3] fix: preserve separators inside nested markdown blocks --- docs/specifications/markdown-transcoding.md | 2 +- src/lib/data/markdown-transcode.test.ts | 18 ++++++++++++++++++ src/lib/data/markdown-transcode.ts | 7 +++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/specifications/markdown-transcoding.md b/docs/specifications/markdown-transcoding.md index b717a16..5740730 100644 --- a/docs/specifications/markdown-transcoding.md +++ b/docs/specifications/markdown-transcoding.md @@ -9,7 +9,7 @@ Per the PRD: CommonMark + GFM (tables, task-list checkboxes, strikethrough) as t - **Read path** (`Y.Text` → Markdown): walk the `Y.Text`'s formatted ranges, emit standard Markdown syntax for each mark (`**bold**`, `` `code` ``, `[text](url)`), plus `@mention` for a `mention` mark and `[[Title]]` for a `link` mark whose href uses an internal `record:` scheme — i.e. an in-block wiki-link to another Document/Collection, resolved to that target's title at read time. - **Write path** (Markdown → `Y.Text`): parse with a standard CommonMark+GFM parser (e.g., `remark` — pure JS, no framework coupling, fits a bespoke SvelteKit-side build), walk the resulting AST, apply `Y.Text.format()` calls for each inline mark, and resolve `@mention`/`[[...]]` tokens against the workspace's actor/record indices before writing. -- **Block separators on the write path:** when one `markdown` value contains adjacent block-level nodes, preserve their boundary as a blank-line (`\n\n`) run in the block's flat `RichText`. This keeps paragraphs from concatenating while allowing the ordinary read path to emit the same Markdown boundary. +- **Block separators on the write path:** when one `markdown` value contains adjacent block-level nodes, preserve their boundary as a blank-line (`\n\n`) run in the block's flat `RichText`. This applies inside blockquotes and list items too, while inline children remain adjacent. This keeps paragraphs from concatenating while allowing the ordinary read path to emit the same Markdown boundary. - **Not the same thing as a `relation`-typed Collection property.** `[[Title]]` above is block-content markup — a wiki-link inside a paragraph's `Y.Text` — but once written it stores the resolved target's ID (`record:`, not the title) as the run's `link` mark; the title shown is re-resolved from that ID every time the block is read (via `resolveInternalLinkTarget`, see `internal-links.md`), so an edited target title does **not** break the link, and a deleted target renders as an explicit `[[Deleted page]]` rather than falling back to the stale cached title. Title matching only happens on the way _in_: parsing typed/pasted Markdown containing `[[Title]]` with no ID yet, where the title is looked up against the workspace's Documents/Collections to find the ID to store (first match wins on duplicate titles — this is the only place title ambiguity matters). A `relation`-typed property (`data-model.md` §1) is a different boundary entirely: it's a plain array of record IDs stored directly on `WorkspaceRecord.properties`, set and read as-is by `write_record`'s `properties` argument and `query_collection` — it is never transcoded through `Y.Text` or Markdown at all, so title matching (and its duplicate-title ambiguity) never applies to it in either direction. - **`page_link` blocks are not part of this inline encoding.** A page_link's target lives on the block record itself (`referencedRecordId`), not inside its `Y.Text`. The read path (`get_document`) still renders it as `[[Target Title]]` for display — or `[[Deleted page]]` plus an explicit `linkBroken: true` field once the target is deleted, see `internal-links.md` — re-resolving the title from that ID the same way an inline wiki-link does. There is still no path that parses `[[Title]]` Markdown text back into `referencedRecordId` — title-based resolution deliberately never applies to `page_link` (`internal-links.md` §4) — but a page_link's target can now be set directly, by ID, through `create_record`'s and `write_record`'s `referencedRecordId` field (`mcp-tools.md`), not just from the editor UI's document picker. - **A styled `callout` block's markdown is also not part of this inline encoding** (issue #42) — same "block-level concern, not `Y.Text` content" split as `page_link` above. `renderRecordMarkdown` (`src/lib/mcp/document-projection.ts`) prefixes a preset-styled callout's already-transcoded inline content with a GitHub-alert-style marker line — `> [!NOTE]`, `> [!TIP]`, `> [!CAUTION]`, or `> [!DANGER]` — GitHub's own nearest convention, reused for the keyword only: GitHub itself defines NOTE/TIP/IMPORTANT/WARNING/CAUTION, not this repo's four (`data-model.md`'s `CalloutPreset`), and there is no CommonMark blockquote parser anywhere in this codebase to begin with (this module is scoped to inline marks, not block-level Markdown at all). This is therefore **read-direction only** — a custom-styled callout (arbitrary icon+color, no alert-syntax equivalent) and an unstyled one both still render as plain inline content, unchanged from before #42, and there is no write-side parsing that turns an incoming `> [!X]` line back into `calloutStyle`: like `checked`/`collapsed` (`data-model.md` §1), a callout's style has no MCP write path at all (`create_record`/`write_record` don't accept it) — it's UI-only, set through the picker in `rich-text-toolbar.md` §7. `get_document` does expose `calloutStyle` read-only, the same way it already exposes `checked`/`collapsed`. diff --git a/src/lib/data/markdown-transcode.test.ts b/src/lib/data/markdown-transcode.test.ts index e6e0e6d..0f8db42 100644 --- a/src/lib/data/markdown-transcode.test.ts +++ b/src/lib/data/markdown-transcode.test.ts @@ -33,6 +33,24 @@ describe('markdown transcoding', () => { expect(richTextToMarkdown(doc, richText)).toBe('Paragraph one\\.\n\nParagraph two\\.'); }); + it.each([ + '> Paragraph one.\n>\n> Paragraph two.', + '- Paragraph one.\n\n Paragraph two.', + '> > Paragraph one.\n> >\n> > Paragraph two.' + ])('preserves nested block separators in %s', (markdown) => { + const richText = markdownToRichText(new Y.Doc(), markdown); + expect(richText.runs.map((run) => run.text).join('')).toBe('Paragraph one.\n\nParagraph two.'); + }); + + it('keeps inline formatting adjacent inside a blockquote', () => { + const richText = markdownToRichText( + new Y.Doc(), + '> plain **bold** [link](https://example.com)' + ); + expect(richText.runs.map((run) => run.text).join('')).toBe('plain bold link'); + expect(richText.runs.find((run) => run.text === 'bold')?.marks.bold).toBe(true); + }); + it('parses @mention into a mention-marked run', () => { const doc = new Y.Doc(); const richText = markdownToRichText(doc, 'ping @local please'); diff --git a/src/lib/data/markdown-transcode.ts b/src/lib/data/markdown-transcode.ts index 64324b1..9141840 100644 --- a/src/lib/data/markdown-transcode.ts +++ b/src/lib/data/markdown-transcode.ts @@ -105,7 +105,10 @@ export function markdownToRichText(doc: Y.Doc, markdown: string): RichText { function collectRuns(doc: Y.Doc, node: MdastNode, marks: TextMarks, runs: MutableRun[]): void { switch (node.type) { case 'root': - collectRootRuns(doc, node.children ?? [], marks, runs); + case 'blockquote': + case 'list': + case 'listItem': + collectBlockRuns(doc, node.children ?? [], marks, runs); return; case 'text': splitSpecialTokens(doc, node.value ?? '', marks, runs); @@ -132,7 +135,7 @@ function collectRuns(doc: Y.Doc, node: MdastNode, marks: TextMarks, runs: Mutabl } } -function collectRootRuns( +function collectBlockRuns( doc: Y.Doc, children: MdastNode[], marks: TextMarks, From ea0795cdf6da4112ee12344a77273fbf378856ac Mon Sep 17 00:00:00 2001 From: eunwoo song Date: Sat, 12 Sep 2026 15:05:59 +0900 Subject: [PATCH 3/3] docs: explain markdown run collection boundaries Address the review summary docstring warning for the two private collectors. The public converter already has JSDoc. Prettier, ESLint, and all 16 markdown-transcoding tests pass. --- src/lib/data/markdown-transcode.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/data/markdown-transcode.ts b/src/lib/data/markdown-transcode.ts index 9141840..75cf88c 100644 --- a/src/lib/data/markdown-transcode.ts +++ b/src/lib/data/markdown-transcode.ts @@ -102,6 +102,7 @@ export function markdownToRichText(doc: Y.Doc, markdown: string): RichText { return { runs: runs.filter((r) => r.text.length > 0) }; } +/** Append inline text runs for a node, preserving marks and nested block boundaries. */ function collectRuns(doc: Y.Doc, node: MdastNode, marks: TextMarks, runs: MutableRun[]): void { switch (node.type) { case 'root': @@ -135,6 +136,7 @@ function collectRuns(doc: Y.Doc, node: MdastNode, marks: TextMarks, runs: Mutabl } } +/** Join nonempty block children with blank lines without separating inline siblings. */ function collectBlockRuns( doc: Y.Doc, children: MdastNode[],