Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/specifications/markdown-transcoding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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:<id>`, 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`.
Expand Down
27 changes: 27 additions & 0 deletions src/lib/data/markdown-transcode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,33 @@ 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.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');
Expand Down
23 changes: 23 additions & 0 deletions src/lib/data/markdown-transcode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,15 @@ 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':
case 'blockquote':
case 'list':
case 'listItem':
collectBlockRuns(doc, node.children ?? [], marks, runs);
return;
case 'text':
splitSpecialTokens(doc, node.value ?? '', marks, runs);
return;
Expand All @@ -129,6 +136,22 @@ 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[],
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)) {
Expand Down
10 changes: 10 additions & 0 deletions src/lib/services/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down