From 93d4ec27c937d5f7a280b47ef07058f2a934fc94 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 10:05:24 -0500 Subject: [PATCH 01/73] Add 2.0 internal document model with partition invariants Introduce the markdown-patch 2.0 internal model: an ordered tree of sections that owns whitespace via a first-class trailingGap field and losslessly partitions the document. buildModel() splits frontmatter, tokenizes a line-ending-normalized copy (marked collapses CRLF in token.raw, which would otherwise drift byte offsets), builds the section tree by heading depth, overlays ^id blocks onto their containing sections, and computes a short content-hash version token. Every byte of the content region belongs to exactly one section's marker, content, or trailingGap, so serializeModel() reconstructs the source exactly. This is proved by a partition/round-trip property suite over the new conformance fixtures, a set of hand-crafted boundary cases, and 500 randomly generated documents (mixed LF/CRLF, optional frontmatter, dropped final newlines). This is the foundation pass only: the existing map.ts/patch.ts engine is untouched and remains the live implementation. The patch engine, public map projection, and Obsidian conformance goldens follow in later commits. Co-Authored-By: Claude Fable 5 --- src/model.ts | 448 ++++++++++++++++++++ src/tests/conformance/block-kinds.md | 19 + src/tests/conformance/code-fence-heading.md | 14 + src/tests/conformance/crlf.md | 7 + src/tests/conformance/duplicate-headings.md | 13 + src/tests/conformance/empty-heading.md | 11 + src/tests/conformance/nested-lists.md | 14 + src/tests/conformance/skipped-levels.md | 11 + src/tests/conformance/table-eof.md | 10 + src/tests/model.property.test.ts | 215 ++++++++++ 10 files changed, 762 insertions(+) create mode 100644 src/model.ts create mode 100644 src/tests/conformance/block-kinds.md create mode 100644 src/tests/conformance/code-fence-heading.md create mode 100644 src/tests/conformance/crlf.md create mode 100644 src/tests/conformance/duplicate-headings.md create mode 100644 src/tests/conformance/empty-heading.md create mode 100644 src/tests/conformance/nested-lists.md create mode 100644 src/tests/conformance/skipped-levels.md create mode 100644 src/tests/conformance/table-eof.md create mode 100644 src/tests/model.property.test.ts diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..993b404 --- /dev/null +++ b/src/model.ts @@ -0,0 +1,448 @@ +import * as marked from "marked"; +import { parse as parseYaml } from "yaml"; +import { createHash } from "crypto"; + +import { DocumentRange } from "./types.js"; +import { + CAN_INCLUDE_BLOCK_REFERENCE, + TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE, +} from "./constants.js"; + +/** + * A single section of a document: a heading plus the body that belongs + * directly to it (i.e. up to its first child heading), together with the + * blank-line separator the library owns. Child sections are stored as an + * ordered list; the tree *is* the nesting. Nothing contested is stored here + * (no path, no occurrence counter); those are derived when projecting or + * resolving a target. + */ +export interface SectionNode { + /** `null` for the synthetic document root. `level` is the *source* heading + * depth as written; canonical levels are derived, not stored. */ + heading: { text: string; level: number } | null; + /** The heading line (`# Foo\n`); `null` for the root. */ + marker: DocumentRange | null; + /** The section's direct body, excluding {@link trailingGap}. */ + content: DocumentRange; + /** The blank-line separator following {@link content} that the library owns. */ + trailingGap: DocumentRange; + /** Child sections, in document order. */ + children: SectionNode[]; + /** `^id`-bearing blocks that live directly in this section's body. */ + blocks: BlockNode[]; + parent: SectionNode | null; +} + +/** + * A `^id`-bearing block. Blocks are an *overlay* onto the section tree: their + * ranges fall within their containing section's {@link SectionNode.content}, + * they do not tile the document themselves. + */ +export interface BlockNode { + id: string; + /** marked token type: `paragraph`, `table`, `list`, `blockquote`, … */ + kind: string; + /** Column header texts, for `table` blocks only. */ + columns?: string[]; + /** The block's content, excluding the `^id` marker. */ + content: DocumentRange; + /** The `^id` token span. */ + marker: DocumentRange; + /** The blank-line separator following the block. */ + trailingGap: DocumentRange; + section: SectionNode; +} + +export interface FrontmatterEntry { + key: string; + value: unknown; + /** The full `key: value` span within the frontmatter block. */ + entryRange: DocumentRange; + /** The value span within {@link entryRange}. */ + valueRange: DocumentRange; +} + +export interface DocumentModel { + /** Short content hash of the source document; the future `ifMatch` token. */ + version: string; + lineEnding: "\n" | "\r\n"; + frontmatter: { + entries: FrontmatterEntry[]; + /** The `---` … `---` block span, or `null` when there is no frontmatter. */ + block: DocumentRange | null; + }; + root: SectionNode; +} + +interface PreprocessedDocument { + content: string; + contentOffset: number; + frontmatterText: string | null; +} + +const FRONTMATTER_REGEX = + /^---(?:\r\n|\r|\n)(?:---(?:\r\n|\r|\n|$)|([\s\S]*?)(?:\r\n|\r|\n)---(?:\r\n|\r|\n|$))/; + +const preProcess = (document: string): PreprocessedDocument => { + const match = FRONTMATTER_REGEX.exec(document); + if (!match) { + return { content: document, contentOffset: 0, frontmatterText: null }; + } + const contentOffset = match[0].length; + return { + content: document.slice(contentOffset), + contentOffset, + frontmatterText: match[1] ?? "", + }; +}; + +const versionOf = (document: string): string => + createHash("sha256").update(document, "utf8").digest("hex").slice(0, 6); + +/** + * marked collapses every `\r\n` (and lone `\r`) to `\n` in `token.raw`, so + * offsets accumulated from raw lengths drift against a CRLF source. We + * therefore tokenize a normalized copy and keep a map from each normalized + * offset back to the original offset, so stored ranges address the real bytes. + */ +const normalizeLineEndings = ( + input: string +): { normalized: string; toOriginal: number[] } => { + const toOriginal: number[] = []; + let normalized = ""; + let i = 0; + while (i < input.length) { + toOriginal.push(i); + if (input[i] === "\r" && input[i + 1] === "\n") { + normalized += "\n"; + i += 2; + } else if (input[i] === "\r") { + normalized += "\n"; + i += 1; + } else { + normalized += input[i]; + i += 1; + } + } + toOriginal.push(input.length); + return { normalized, toOriginal }; +}; + +/** Translate a normalized-content offset into an absolute original offset. */ +type Abs = (normalizedOffset: number) => number; + +/** + * Split a content-space range into its visible content and its trailing + * blank-line separator. The last visible line keeps exactly one line ending + * as part of the content; any further line endings are the trailing gap. A + * range that is entirely line endings is all gap (an empty section body). + */ +const splitTrailingGap = ( + content: string, + start: number, + end: number +): { contentEnd: number } => { + let visibleEnd = end; + while ( + visibleEnd > start && + (content[visibleEnd - 1] === "\n" || content[visibleEnd - 1] === "\r") + ) { + visibleEnd--; + } + if (visibleEnd === start) { + // Entire range is line endings: no visible content, all gap. + return { contentEnd: start }; + } + // Keep the terminating line ending of the final visible line with content. + let contentEnd = visibleEnd; + if (content[contentEnd] === "\r" && content[contentEnd + 1] === "\n") { + contentEnd += 2; + } else if (content[contentEnd] === "\n" || content[contentEnd] === "\r") { + contentEnd += 1; + } + return { contentEnd }; +}; + +interface HeadingSpan { + text: string; + level: number; + /** content-space offset of the heading line start. */ + markerStart: number; + /** content-space offset just past the heading line's terminator. */ + markerEnd: number; +} + +/** Locate every top-level heading token with exact content-space offsets. */ +const findHeadings = ( + content: string, + tokens: marked.TokensList +): HeadingSpan[] => { + const headings: HeadingSpan[] = []; + let offset = 0; + for (const token of tokens) { + if (token.type === "heading") { + const heading = token as marked.Tokens.Heading; + const markerStart = offset; + let markerEnd = markerStart + heading.raw.trimEnd().length; + if (content[markerEnd] === "\r" && content[markerEnd + 1] === "\n") { + markerEnd += 2; + } else if (content[markerEnd] === "\n" || content[markerEnd] === "\r") { + markerEnd += 1; + } + headings.push({ + text: heading.text.trim(), + level: heading.depth, + markerStart, + markerEnd, + }); + } + offset += token.raw.length; + } + return headings; +}; + +const buildSectionTree = ( + content: string, + abs: Abs, + headings: HeadingSpan[] +): SectionNode => { + const contentLength = content.length; + + const root: SectionNode = { + heading: null, + marker: null, + content: { start: 0, end: 0 }, + trailingGap: { start: 0, end: 0 }, + children: [], + blocks: [], + parent: null, + }; + + // Root's direct body runs from the top of the content region to the first + // heading (or the whole content region when there are no headings). + const rootBodyStart = 0; + const rootBodyEnd = headings.length ? headings[0].markerStart : contentLength; + const rootSplit = splitTrailingGap(content, rootBodyStart, rootBodyEnd); + root.content = { start: abs(rootBodyStart), end: abs(rootSplit.contentEnd) }; + root.trailingGap = { start: abs(rootSplit.contentEnd), end: abs(rootBodyEnd) }; + + const stack: SectionNode[] = [root]; + + headings.forEach((heading, index) => { + // A section's direct body ends at the next heading of any level (which is + // either its first child or the heading that closes it), or the end of the + // content region. + const bodyStart = heading.markerEnd; + const bodyEnd = + index + 1 < headings.length + ? headings[index + 1].markerStart + : contentLength; + const split = splitTrailingGap(content, bodyStart, bodyEnd); + + const node: SectionNode = { + heading: { text: heading.text, level: heading.level }, + marker: { start: abs(heading.markerStart), end: abs(heading.markerEnd) }, + content: { start: abs(bodyStart), end: abs(split.contentEnd) }, + trailingGap: { start: abs(split.contentEnd), end: abs(bodyEnd) }, + children: [], + blocks: [], + parent: null, + }; + + while ( + stack.length > 1 && + stack[stack.length - 1].heading!.level >= heading.level + ) { + stack.pop(); + } + const parent = stack[stack.length - 1]; + node.parent = parent; + parent.children.push(node); + stack.push(node); + }); + + return root; +}; + +const forEachSection = ( + root: SectionNode, + visit: (node: SectionNode) => void +): void => { + visit(root); + for (const child of root.children) { + forEachSection(child, visit); + } +}; + +/** Find the deepest section whose direct body contains the given offset. */ +const sectionContaining = (root: SectionNode, offset: number): SectionNode => { + let best = root; + forEachSection(root, (node) => { + if (offset >= node.content.start && offset < node.trailingGap.end) { + // Prefer the deepest (most specific) containing section. + if (node.content.start >= best.content.start) { + best = node; + } + } + }); + return best; +}; + +const BLOCK_REFERENCE_REGEX = /[^\S\r\n]*\^([a-zA-Z0-9_-]+)\s*$/; + +const findBlocks = ( + content: string, + abs: Abs, + tokens: marked.TokensList, + root: SectionNode +): BlockNode[] => { + const blocks: BlockNode[] = []; + let searchFrom = 0; + + marked.walkTokens(tokens, (token) => { + const found = content.indexOf(token.raw, searchFrom); + if (found === -1) { + // Inner blockquote tokens omit their `> ` prefix and never appear + // verbatim; skip them rather than corrupt the running offset. + return; + } + searchFrom = found; + const match = BLOCK_REFERENCE_REGEX.exec(token.raw); + if (!CAN_INCLUDE_BLOCK_REFERENCE.includes(token.type) || !match) { + return; + } + const id = match[1]; + if (!id) { + return; + } + const contentStart = found; + const contentEnd = found + match.index; + const markerStart = found + match.index; + const markerEnd = found + token.raw.length; + const section = sectionContaining(root, abs(contentStart)); + const block: BlockNode = { + id, + kind: token.type, + content: { start: abs(contentStart), end: abs(contentEnd) }, + marker: { start: abs(markerStart), end: abs(markerEnd) }, + trailingGap: { start: abs(markerEnd), end: abs(markerEnd) }, + section, + }; + if (token.type === "table") { + block.columns = (token as marked.Tokens.Table).header.map( + (cell) => cell.text + ); + } + blocks.push(block); + section.blocks.push(block); + }); + + return blocks; +}; + +const findLineEnding = (document: string): "\n" | "\r\n" => + document.indexOf("\r\n") > -1 ? "\r\n" : "\n"; + +const buildFrontmatter = ( + document: string, + frontmatterText: string | null, + contentOffset: number +): DocumentModel["frontmatter"] => { + if (frontmatterText === null) { + return { entries: [], block: null }; + } + const block: DocumentRange = { start: 0, end: contentOffset }; + const parsed = parseYaml(frontmatterText.trim()); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { entries: [], block }; + } + const parsedRecord = parsed as Record; + + // The inner YAML begins just past the opening delimiter and runs for + // `frontmatterText.length`; the closing `---` follows. Locate each + // top-level `key:` at a line start so callers get real ranges (used later + // for key-scoped splicing). + const openingLength = + /^---(?:\r\n|\r|\n)/.exec(document)?.[0].length ?? 4; + const innerStart = openingLength; + const innerEnd = innerStart + frontmatterText.length; + + const keyStarts: Array<{ key: string; start: number; colon: number }> = []; + let lineStart = innerStart; + for (const line of frontmatterText.split(/(?<=\n)/)) { + const keyMatch = /^([^\s:][^:]*):/.exec(line); + if (keyMatch && keyMatch[1].trim() in parsedRecord) { + keyStarts.push({ + key: keyMatch[1].trim(), + start: lineStart, + colon: lineStart + keyMatch[0].length, + }); + } + lineStart += line.length; + } + + const entries: FrontmatterEntry[] = keyStarts.map((entry, idx) => { + const end = keyStarts[idx + 1]?.start ?? innerEnd; + return { + key: entry.key, + value: parsedRecord[entry.key], + entryRange: { start: entry.start, end }, + valueRange: { start: entry.colon, end }, + }; + }); + + return { entries, block }; +}; + +/** + * Build the internal document model: an ordered tree of sections (each owning + * its heading, direct body and trailing gap), a block overlay indexed into + * those sections, and the frontmatter. Every byte of the content region + * belongs to exactly one section's marker, content or trailingGap, so the tree + * losslessly partitions the document. + */ +export const buildModel = (document: string): DocumentModel => { + const { content, contentOffset, frontmatterText } = preProcess(document); + const { normalized, toOriginal } = normalizeLineEndings(content); + const abs: Abs = (n) => contentOffset + toOriginal[n]; + const tokens = new marked.Lexer().lex(normalized); + const headings = findHeadings(normalized, tokens); + const root = buildSectionTree(normalized, abs, headings); + findBlocks(normalized, abs, tokens, root); + + return { + version: versionOf(document), + lineEnding: findLineEnding(document), + frontmatter: buildFrontmatter(document, frontmatterText, contentOffset), + root, + }; +}; + +/** Iterate every section, root first, in document order. Exposed for tests. */ +export const eachSection = forEachSection; + +/** + * Reconstruct the source document from the section partition. Used to prove the + * tree tiles the content region losslessly. + */ +export const serializeModel = ( + document: string, + model: DocumentModel +): string => { + const frontmatter = model.frontmatter.block + ? document.slice(model.frontmatter.block.start, model.frontmatter.block.end) + : ""; + const parts: string[] = []; + const emit = (node: SectionNode): void => { + if (node.marker) { + parts.push(document.slice(node.marker.start, node.marker.end)); + } + parts.push(document.slice(node.content.start, node.content.end)); + parts.push(document.slice(node.trailingGap.start, node.trailingGap.end)); + for (const child of node.children) { + emit(child); + } + }; + emit(model.root); + return frontmatter + parts.join(""); +}; diff --git a/src/tests/conformance/block-kinds.md b/src/tests/conformance/block-kinds.md new file mode 100644 index 0000000..3bd7900 --- /dev/null +++ b/src/tests/conformance/block-kinds.md @@ -0,0 +1,19 @@ +# Blocks + +A plain paragraph with a trailing block id. ^para1 + +- List item one +- List item two + +^list1 + +| Col A | Col B | +| ----- | ----- | +| 1 | 2 | + +^table1 + +> A blockquote line one. +> A blockquote line two. + +^quote1 diff --git a/src/tests/conformance/code-fence-heading.md b/src/tests/conformance/code-fence-heading.md new file mode 100644 index 0000000..17fe64a --- /dev/null +++ b/src/tests/conformance/code-fence-heading.md @@ -0,0 +1,14 @@ +# Guide + +Some intro text. + +```markdown +# Not a real heading +## Also not a heading +``` + +More text after the fence. + +## Real Subsection + +Body of the real subsection. diff --git a/src/tests/conformance/crlf.md b/src/tests/conformance/crlf.md new file mode 100644 index 0000000..127337e --- /dev/null +++ b/src/tests/conformance/crlf.md @@ -0,0 +1,7 @@ +# Title + +Paragraph one under title. + +## Sub + +Paragraph under sub. ^b1 diff --git a/src/tests/conformance/duplicate-headings.md b/src/tests/conformance/duplicate-headings.md new file mode 100644 index 0000000..9048808 --- /dev/null +++ b/src/tests/conformance/duplicate-headings.md @@ -0,0 +1,13 @@ +# Log + +## 2026-07-18 + +First entry body. + +## 2026-07-18 + +Second entry with the same heading text. + +## 2026-07-19 + +Third entry. diff --git a/src/tests/conformance/empty-heading.md b/src/tests/conformance/empty-heading.md new file mode 100644 index 0000000..03cb8f7 --- /dev/null +++ b/src/tests/conformance/empty-heading.md @@ -0,0 +1,11 @@ +# + +Body under an empty-text heading. + +## Named + +Body under a named subsection. + +## + +Body under an empty-text subsection. diff --git a/src/tests/conformance/nested-lists.md b/src/tests/conformance/nested-lists.md new file mode 100644 index 0000000..79d8620 --- /dev/null +++ b/src/tests/conformance/nested-lists.md @@ -0,0 +1,14 @@ +# Tasks + +- Item one +- Item two + + Loose paragraph inside item two. + +- Item three + - Nested a + - Nested b + +## Done + +- Finished item diff --git a/src/tests/conformance/skipped-levels.md b/src/tests/conformance/skipped-levels.md new file mode 100644 index 0000000..5827910 --- /dev/null +++ b/src/tests/conformance/skipped-levels.md @@ -0,0 +1,11 @@ +# Top + +Intro under top. + +### Deep child + +This h3 sits directly under an h1, skipping h2. + +# Second Top + +Body of second top. diff --git a/src/tests/conformance/table-eof.md b/src/tests/conformance/table-eof.md new file mode 100644 index 0000000..3efb2f0 --- /dev/null +++ b/src/tests/conformance/table-eof.md @@ -0,0 +1,10 @@ +--- +title: table at eof +--- + +# Journal + +| Date | Event | +| ---------- | ------------- | +| 2026-01-01 | Initial entry | +| 2026-01-02 | Second entry | diff --git a/src/tests/model.property.test.ts b/src/tests/model.property.test.ts new file mode 100644 index 0000000..4cfb8a3 --- /dev/null +++ b/src/tests/model.property.test.ts @@ -0,0 +1,215 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { + buildModel, + serializeModel, + eachSection, + DocumentModel, + SectionNode, +} from "../model"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const CONFORMANCE_DIR = path.join(__dirname, "conformance"); + +const readConformanceFixtures = (): Array<{ name: string; text: string }> => + fs + .readdirSync(CONFORMANCE_DIR) + .filter((f) => f.endsWith(".md")) + .map((f) => ({ + name: f, + text: fs.readFileSync(path.join(CONFORMANCE_DIR, f), "utf-8"), + })); + +// A spread of hand-crafted documents exercising boundary conditions the +// fixtures may not: no trailing newline, no frontmatter, empty body sections, +// headings with no blank-line separators, and a bare preamble. +const CRAFTED: Array<{ name: string; text: string }> = [ + { name: "empty", text: "" }, + { name: "only-text", text: "Just a paragraph, no headings.\n" }, + { name: "no-trailing-newline", text: "# H\n\nBody with no final newline." }, + { name: "heading-only", text: "# Alone\n" }, + { name: "heading-only-no-newline", text: "# Alone" }, + { name: "no-blank-separators", text: "# A\nbody a\n# B\nbody b\n" }, + { + name: "preamble-then-heading", + text: "Preamble line.\n\n# First\n\nBody.\n", + }, + { name: "consecutive-headings", text: "# A\n## B\n### C\n\nbody\n" }, + { + name: "many-blank-lines", + text: "# A\n\nbody a\n\n\n\n# B\n\nbody b\n", + }, +]; + +const allDocuments = (): Array<{ name: string; text: string }> => [ + ...readConformanceFixtures(), + ...CRAFTED, +]; + +const collectSections = (model: DocumentModel): SectionNode[] => { + const nodes: SectionNode[] = []; + eachSection(model.root, (n) => nodes.push(n)); + return nodes; +}; + +describe("model partition invariants", () => { + describe.each(allDocuments())("$name", ({ text }) => { + const model = buildModel(text); + + test("round-trips: serializing the model reproduces the source", () => { + expect(serializeModel(text, model)).toEqual(text); + }); + + test("every section range is well-formed (start <= end)", () => { + for (const node of collectSections(model)) { + if (node.marker) { + expect(node.marker.start).toBeLessThanOrEqual(node.marker.end); + } + expect(node.content.start).toBeLessThanOrEqual(node.content.end); + expect(node.trailingGap.start).toBeLessThanOrEqual(node.trailingGap.end); + } + }); + + test("content and trailingGap are contiguous per section", () => { + for (const node of collectSections(model)) { + expect(node.content.end).toEqual(node.trailingGap.start); + if (node.marker) { + expect(node.marker.end).toEqual(node.content.start); + } + } + }); + + test("trailingGap is whitespace only", () => { + for (const node of collectSections(model)) { + const gap = text.slice(node.trailingGap.start, node.trailingGap.end); + expect(gap).toMatch(/^\s*$/); + } + }); + + test("block ranges fall inside their containing section body", () => { + for (const node of collectSections(model)) { + for (const block of node.blocks) { + expect(block.content.start).toBeGreaterThanOrEqual(node.content.start); + expect(block.marker.end).toBeLessThanOrEqual(node.trailingGap.end); + expect(block.section).toBe(node); + } + } + }); + }); +}); + +describe("model structure", () => { + test("duplicate headings become distinct sibling sections", () => { + const doc = + "# Log\n\n## 2026-07-18\n\nfirst\n\n## 2026-07-18\n\nsecond\n"; + const model = buildModel(doc); + const log = model.root.children[0]; + expect(log.children).toHaveLength(2); + expect(log.children[0].heading?.text).toEqual("2026-07-18"); + expect(log.children[1].heading?.text).toEqual("2026-07-18"); + expect(text_of(doc, log.children[0])).toContain("first"); + expect(text_of(doc, log.children[1])).toContain("second"); + }); + + test("skipped heading levels still nest by depth", () => { + const doc = "# Top\n\nintro\n\n### Deep\n\ndeep body\n\n# Second\n\nx\n"; + const model = buildModel(doc); + expect(model.root.children.map((c) => c.heading?.text)).toEqual([ + "Top", + "Second", + ]); + const top = model.root.children[0]; + expect(top.children.map((c) => c.heading?.text)).toEqual(["Deep"]); + expect(top.children[0].heading?.level).toEqual(3); + }); + + test("preamble before the first heading belongs to the root", () => { + const doc = "Preamble.\n\n# First\n\nbody\n"; + const model = buildModel(doc); + expect(text_of(doc, model.root)).toContain("Preamble."); + expect(model.root.heading).toBeNull(); + }); + + test("frontmatter block is excluded from the section tree", () => { + const doc = "---\ntitle: t\n---\n\n# H\n\nbody\n"; + const model = buildModel(doc); + expect(model.frontmatter.block).not.toBeNull(); + expect(model.frontmatter.entries.map((e) => e.key)).toEqual(["title"]); + // Root content starts at or after the frontmatter block. + expect(model.root.content.start).toBeGreaterThanOrEqual( + model.frontmatter.block!.end + ); + }); +}); + +const text_of = (doc: string, node: SectionNode): string => + doc.slice(node.content.start, node.content.end); + +// A small deterministic PRNG so failures are reproducible from their seed. +const mulberry32 = (seed: number): (() => number) => { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; + +const randomDocument = (rand: () => number): string => { + const eol = rand() < 0.3 ? "\r\n" : "\n"; + const pick = (xs: T[]): T => xs[Math.floor(rand() * xs.length)]; + const parts: string[] = []; + if (rand() < 0.4) { + parts.push(`---${eol}title: t${eol}count: ${Math.floor(rand() * 9)}${eol}---${eol}`); + } + const blockCount = Math.floor(rand() * 8); + for (let i = 0; i < blockCount; i++) { + const kind = pick(["heading", "para", "blank", "list", "block-id"]); + if (kind === "heading") { + const level = 1 + Math.floor(rand() * 6); + const text = rand() < 0.15 ? "" : ` H${i}`; + parts.push(`${"#".repeat(level)}${text}${eol}`); + } else if (kind === "para") { + parts.push(`paragraph ${i}${eol}`); + } else if (kind === "blank") { + parts.push(eol); + } else if (kind === "list") { + parts.push(`- item ${i}a${eol}- item ${i}b${eol}`); + } else if (kind === "block-id") { + parts.push(`paragraph ${i} ^b${i}${eol}${eol}`); + } + } + let doc = parts.join(""); + if (rand() < 0.2 && doc.endsWith(eol)) { + doc = doc.slice(0, -eol.length); // sometimes drop the final newline + } + return doc; +}; + +describe("model partition fuzz", () => { + test("round-trips across 500 randomly generated documents", () => { + for (let seed = 1; seed <= 500; seed++) { + const rand = mulberry32(seed); + const doc = randomDocument(rand); + const model = buildModel(doc); + expect({ seed, out: serializeModel(doc, model) }).toEqual({ + seed, + out: doc, + }); + // Section ranges must tile: each section's trailingGap end meets the + // next boundary and gaps are whitespace only. + eachSection(model.root, (node) => { + expect(node.content.end).toEqual(node.trailingGap.start); + expect(doc.slice(node.trailingGap.start, node.trailingGap.end)).toMatch( + /^\s*$/ + ); + }); + } + }); +}); From ba2c132749c26f40bce1a75c11d900108d090b05 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 10:07:13 -0500 Subject: [PATCH 02/73] Add public map projection derived from the 2.0 model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit projectMap() turns the internal model into the terse, context-cheap public view: a version token, top-level frontmatter field names, one null-padded array per heading (array length = level, null for skipped levels, "" for empty-text headings), and bare block ids — all in document order, with no in-band grammar. Co-Authored-By: Claude Fable 5 --- src/projection.ts | 60 ++++++++++++++++++++++++++++++++++++ src/tests/projection.test.ts | 52 +++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/projection.ts create mode 100644 src/tests/projection.test.ts diff --git a/src/projection.ts b/src/projection.ts new file mode 100644 index 0000000..c6c6dde --- /dev/null +++ b/src/projection.ts @@ -0,0 +1,60 @@ +import { DocumentModel, SectionNode } from "./model.js"; + +/** + * The terse, context-cheap public view of a document, derived from the + * {@link DocumentModel}. It carries no in-band grammar: headings are plain + * null-padded arrays and block references are bare ids. + */ +export interface PublicMap { + /** Content-hash token; pass back as an `ifMatch` precondition. */ + version: string; + /** Top-level frontmatter field names, in document order. */ + frontmatterFields: string[]; + /** + * One entry per heading, in document order. Each entry is an array whose + * length equals the heading's level: index `i` holds the text of the heading + * at level `i + 1` on the path to this heading, `null` where that level is + * skipped, and `""` for an empty-text heading. + */ + headings: (string | null)[][]; + /** Block reference ids, bare (no `^`), in document order. */ + blocks: string[]; +} + +const headingPath = (node: SectionNode): (string | null)[] => { + const level = node.heading!.level; + const path: (string | null)[] = new Array(level).fill(null); + let current: SectionNode | null = node; + while (current && current.heading) { + path[current.heading.level - 1] = current.heading.text; + current = current.parent; + } + return path; +}; + +/** Project the internal model into the public map consumers receive. */ +export const projectMap = (model: DocumentModel): PublicMap => { + const headings: (string | null)[][] = []; + const blocks: string[] = []; + + const walk = (node: SectionNode): void => { + if (node.heading) { + headings.push(headingPath(node)); + } + // A section's own blocks precede its child sections in document order. + for (const block of node.blocks) { + blocks.push(block.id); + } + for (const child of node.children) { + walk(child); + } + }; + walk(model.root); + + return { + version: model.version, + frontmatterFields: model.frontmatter.entries.map((entry) => entry.key), + headings, + blocks, + }; +}; diff --git a/src/tests/projection.test.ts b/src/tests/projection.test.ts new file mode 100644 index 0000000..deb7acf --- /dev/null +++ b/src/tests/projection.test.ts @@ -0,0 +1,52 @@ +import { buildModel } from "../model"; +import { projectMap } from "../projection"; + +describe("projectMap", () => { + test("produces the first-pass public shape", () => { + const doc = + "---\n" + + "status: draft\n" + + "reviewers:\n" + + "- alice\n" + + "---\n\n" + + "# Overview\n\n" + + "The thesis. ^thesis\n\n" + + "### Known quirks\n\n" + + "| quirk | fixed |\n| --- | --- |\n| a | b |\n\n^quirks\n\n" + + "# Development Logs\n\n" + + "## 2026-07-18\n\nfirst\n\n" + + "## 2026-07-18\n\nsecond\n"; + const map = projectMap(buildModel(doc)); + + expect(map.frontmatterFields).toEqual(["status", "reviewers"]); + expect(map.headings).toEqual([ + ["Overview"], + ["Overview", null, "Known quirks"], + ["Development Logs"], + ["Development Logs", "2026-07-18"], + ["Development Logs", "2026-07-18"], + ]); + expect(map.blocks).toEqual(["thesis", "quirks"]); + expect(map.version).toMatch(/^[0-9a-f]{6}$/); + }); + + test("null-pads skipped levels and preserves empty heading text", () => { + const doc = "# \n\nbody\n\n#### Deep\n\ndeep\n"; + const map = projectMap(buildModel(doc)); + expect(map.headings).toEqual([[""], ["", null, null, "Deep"]]); + }); + + test("version tracks content and matches the model", () => { + const a = buildModel("# A\n\nbody\n"); + const b = buildModel("# A\n\nbody changed\n"); + expect(projectMap(a).version).toEqual(a.version); + expect(projectMap(a).version).not.toEqual(projectMap(b).version); + }); + + test("headings and blocks are empty for a bare document", () => { + const map = projectMap(buildModel("just text, no structure\n")); + expect(map.headings).toEqual([]); + expect(map.blocks).toEqual([]); + expect(map.frontmatterFields).toEqual([]); + }); +}); From fa0cac5b72b26dc81a48f997c1a9c0780e6970d4 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 10:12:04 -0500 Subject: [PATCH 03/73] Add Obsidian conformance harness and capture tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conformance.test.ts cross-validates the model against Obsidian's own parse: for each fixture with a frozen golden it asserts heading levels/texts/start offsets and the block id set match Obsidian's metadataCache, and that each model block falls within Obsidian's block span. Fixtures lacking a golden are reported as todo so the suite stays green until goldens are captured. Goldens are produced by capture-snippet.js, a throwaway Obsidian DevTools snippet that dumps metadataCache offsets for the fixtures — it touches no plugin API and ships nothing. conformance/README.md documents the one-time capture flow. Co-Authored-By: Claude Fable 5 --- src/tests/conformance.test.ts | 115 +++++++++++++++++++++++ src/tests/conformance/README.md | 32 +++++++ src/tests/conformance/capture-snippet.js | 70 ++++++++++++++ src/tests/model.property.test.ts | 2 +- 4 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 src/tests/conformance.test.ts create mode 100644 src/tests/conformance/README.md create mode 100644 src/tests/conformance/capture-snippet.js diff --git a/src/tests/conformance.test.ts b/src/tests/conformance.test.ts new file mode 100644 index 0000000..1621766 --- /dev/null +++ b/src/tests/conformance.test.ts @@ -0,0 +1,115 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { buildModel, eachSection, SectionNode } from "../model"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const CONFORMANCE_DIR = path.join(__dirname, "conformance"); + +interface Golden { + headings: Array<{ level: number; text: string; start: number; end: number }>; + sections: Array<{ type: string; start: number; end: number }>; + blocks: Record; + listItems: Array<{ start: number; end: number }>; +} + +interface Fixture { + name: string; + text: string; + golden: Golden | null; +} + +const loadFixtures = (): Fixture[] => + fs + .readdirSync(CONFORMANCE_DIR) + .filter((f) => f.endsWith(".md") && f !== "README.md") + .map((f) => { + const goldenPath = path.join( + CONFORMANCE_DIR, + f.replace(/\.md$/, ".obsidian.json") + ); + return { + name: f, + text: fs.readFileSync(path.join(CONFORMANCE_DIR, f), "utf-8"), + golden: fs.existsSync(goldenPath) + ? (JSON.parse(fs.readFileSync(goldenPath, "utf-8")) as Golden) + : null, + }; + }); + +const sectionsOf = (text: string): SectionNode[] => { + const model = buildModel(text); + const nodes: SectionNode[] = []; + eachSection(model.root, (n) => { + if (n.heading) { + nodes.push(n); + } + }); + return nodes; +}; + +const blockIds = (text: string): Set => { + const model = buildModel(text); + const ids = new Set(); + eachSection(model.root, (n) => n.blocks.forEach((b) => ids.add(b.id))); + return ids; +}; + +const fixtures = loadFixtures(); +const withGolden = fixtures.filter((f) => f.golden !== null); +const withoutGolden = fixtures.filter((f) => f.golden === null); + +describe("Obsidian conformance", () => { + if (withGolden.length === 0) { + // No goldens captured yet: keep the suite green but make the gap visible. + test.todo( + "capture goldens with src/tests/conformance/capture-snippet.js (see conformance/README.md)" + ); + } + + const checkFixture = ({ text, golden }: Fixture): void => { + const g = golden as Golden; + const sections = sectionsOf(text); + + test("model heading levels, texts and start offsets match Obsidian", () => { + const modelHeadings = sections.map((s) => ({ + level: s.heading!.level, + text: s.heading!.text, + start: s.marker!.start, + })); + const obsidianHeadings = g.headings.map((h) => ({ + level: h.level, + text: h.text, + start: h.start, + })); + expect(modelHeadings).toEqual(obsidianHeadings); + }); + + test("model block ids match Obsidian's block ids", () => { + expect([...blockIds(text)].sort()).toEqual(Object.keys(g.blocks).sort()); + }); + + test("each model block falls within Obsidian's block span", () => { + const model = buildModel(text); + eachSection(model.root, (node) => { + for (const block of node.blocks) { + const span = g.blocks[block.id]; + expect(span).toBeDefined(); + expect(block.content.start).toBeGreaterThanOrEqual(span.start); + expect(block.marker.end).toBeLessThanOrEqual(span.end + 1); + } + }); + }); + }; + + for (const fixture of withGolden) { + describe(fixture.name, () => checkFixture(fixture)); + } + + for (const fixture of withoutGolden) { + test.todo(`capture golden for ${fixture.name}`); + } +}); diff --git a/src/tests/conformance/README.md b/src/tests/conformance/README.md new file mode 100644 index 0000000..ad8a7cd --- /dev/null +++ b/src/tests/conformance/README.md @@ -0,0 +1,32 @@ +# Obsidian conformance fixtures + +These `*.md` fixtures cross-validate markdown-patch's internal model against +Obsidian's own parse (`metadataCache`). Obsidian is the source of truth for what +counts as a heading, section, or block, so the model must agree with it on +boundaries. + +## How it works + +- Each `*.md` fixture has a frozen golden `*.obsidian.json` beside it, captured + from live Obsidian (see below). +- `conformance.test.ts` builds the model for each fixture and asserts that the + heading levels/texts/offsets and the block id set match the golden. +- Fixtures **without** a golden are reported as pending (the suite stays green), + so the capture step can lag behind adding a fixture. + +## Capturing / refreshing goldens + +The goldens are only regenerated when fixtures change — this needs a live +Obsidian instance and is a manual, occasional step: + +1. Copy this folder's `*.md` files into a `conformance/` folder at the root of + any Obsidian vault. +2. Open that vault, open the developer console (Ctrl/Cmd-Shift-I), and wait a + moment for the metadata cache to settle. +3. Paste the contents of [`capture-snippet.js`](./capture-snippet.js) into the + console and run it. It writes `.obsidian.json` beside each fixture in + the vault. +4. Copy the resulting `*.obsidian.json` files back into this folder. + +This is throwaway capture tooling: it is not part of the shipped library and +uses no plugin API. diff --git a/src/tests/conformance/capture-snippet.js b/src/tests/conformance/capture-snippet.js new file mode 100644 index 0000000..1e72aa6 --- /dev/null +++ b/src/tests/conformance/capture-snippet.js @@ -0,0 +1,70 @@ +/* + * Obsidian conformance-capture snippet. + * + * Freezes Obsidian's canonical parse (metadataCache) for the conformance + * fixtures so markdown-patch's model can be cross-validated against it. This is + * throwaway capture tooling — it is not shipped and touches no plugin API. + * + * Usage (run once, re-run when fixtures change): + * 1. Copy every `*.md` file from this folder into a folder named + * `conformance/` at the root of any Obsidian vault. + * 2. Open that vault in Obsidian, then open the developer console + * (Ctrl/Cmd-Shift-I) and let the metadata cache settle for a moment. + * 3. Paste this entire file into the console and press Enter. + * 4. It writes a `.obsidian.json` beside each fixture in the vault's + * `conformance/` folder. Copy those JSON files back into this folder, + * next to the matching `.md` fixture. + * + * The golden shape is intentionally minimal — only offsets markdown-patch's + * model is validated against: + * { headings: [{ level, text, start, end }], + * sections: [{ type, start, end }], + * blocks: { id: { start, end } }, + * listItems:[{ start, end }] } + * where start/end are `position.start.offset` / `position.end.offset`. + */ +(async () => { + const FOLDER = "conformance"; + const offsets = (pos) => ({ start: pos.start.offset, end: pos.end.offset }); + + const files = app.vault + .getMarkdownFiles() + .filter((f) => f.path.startsWith(`${FOLDER}/`)); + + if (files.length === 0) { + console.warn( + `No fixtures found under "${FOLDER}/". Copy the conformance *.md files there first.` + ); + return; + } + + for (const file of files) { + const cache = app.metadataCache.getFileCache(file); + if (!cache) { + console.warn(`No cache yet for ${file.path}; skipping.`); + continue; + } + const golden = { + headings: (cache.headings ?? []).map((h) => ({ + level: h.level, + text: h.heading, + ...offsets(h.position), + })), + sections: (cache.sections ?? []).map((s) => ({ + type: s.type, + ...offsets(s.position), + })), + blocks: Object.fromEntries( + Object.entries(cache.blocks ?? {}).map(([id, b]) => [ + id, + offsets(b.position), + ]) + ), + listItems: (cache.listItems ?? []).map((li) => offsets(li.position)), + }; + const outPath = file.path.replace(/\.md$/, ".obsidian.json"); + await app.vault.adapter.write(outPath, JSON.stringify(golden, null, 2) + "\n"); + console.log(`captured ${outPath}`); + } + console.log(`Done. Captured ${files.length} fixture(s).`); +})(); diff --git a/src/tests/model.property.test.ts b/src/tests/model.property.test.ts index 4cfb8a3..08a437e 100644 --- a/src/tests/model.property.test.ts +++ b/src/tests/model.property.test.ts @@ -18,7 +18,7 @@ const CONFORMANCE_DIR = path.join(__dirname, "conformance"); const readConformanceFixtures = (): Array<{ name: string; text: string }> => fs .readdirSync(CONFORMANCE_DIR) - .filter((f) => f.endsWith(".md")) + .filter((f) => f.endsWith(".md") && f !== "README.md") .map((f) => ({ name: f, text: fs.readFileSync(path.join(CONFORMANCE_DIR, f), "utf-8"), From 173b684bd85d7d45a9ae081f5f384171258801c7 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 10:39:35 -0500 Subject: [PATCH 04/73] Match Obsidian's block-span rules in the 2.0 model buildModel's findBlocks had regressed two behaviors the old getBlockPositions engine relied on, both confirmed against live Obsidian metadataCache offsets: - An isolated `^id` on its own line targets the *preceding* block, not the marker line. Restore the lastBlockDetails-style fallback (the previously unused TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE constant) and record it via a new BlockNode.isolated flag so callers can tell the two cases apart. - Obsidian excludes every trailing newline from a block span (a token's raw may carry one for an inline paragraph or a following blank line for a table). Strip them from block boundaries. Working in normalized-offset space, this also fixes the CRLF off-by-one the old engine had. Co-Authored-By: Claude Fable 5 --- src/model.ts | 102 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 27 deletions(-) diff --git a/src/model.ts b/src/model.ts index 993b404..de7bcb8 100644 --- a/src/model.ts +++ b/src/model.ts @@ -44,9 +44,22 @@ export interface BlockNode { kind: string; /** Column header texts, for `table` blocks only. */ columns?: string[]; - /** The block's content, excluding the `^id` marker. */ + /** + * True when the `^id` sits alone on its own line and therefore targets the + * *preceding* block (Obsidian's isolated-block-reference rule): in that case + * {@link content} is the preceding block's span and {@link marker} is the + * detached `^id` line that follows it. False for an inline `^id` trailing a + * paragraph/table row, where content and marker are contiguous. + */ + isolated: boolean; + /** + * The region the block id addresses. For an inline block this is the token + * text preceding the `^id` marker; for an isolated block it is the whole + * preceding block. Never includes a trailing line ending (Obsidian excludes + * it from block spans). + */ content: DocumentRange; - /** The `^id` token span. */ + /** The `^id` token span, with any trailing line ending excluded. */ marker: DocumentRange; /** The blank-line separator following the block. */ trailingGap: DocumentRange; @@ -290,6 +303,21 @@ const sectionContaining = (root: SectionNode, offset: number): SectionNode => { const BLOCK_REFERENCE_REGEX = /[^\S\r\n]*\^([a-zA-Z0-9_-]+)\s*$/; +/** + * Back up over trailing line endings so a boundary sits just past the last + * visible character (`content` is normalized, so only `\n` occurs). Obsidian's + * block spans exclude every trailing newline — a token's raw may carry one (an + * inline paragraph) or a following blank line (a table) — so block boundaries + * are trimmed the same way. + */ +const stripTrailingEol = (content: string, end: number, start: number): number => { + let trimmed = end; + while (trimmed > start && content[trimmed - 1] === "\n") { + trimmed--; + } + return trimmed; +}; + const findBlocks = ( content: string, abs: Abs, @@ -298,6 +326,11 @@ const findBlocks = ( ): BlockNode[] => { const blocks: BlockNode[] = []; let searchFrom = 0; + // The most recent block-level token an isolated `^id` line can bind to, in + // content space with its trailing newline stripped. Mirrors the old engine's + // `lastBlockDetails` and matches Obsidian, which reports an isolated block's + // position as the preceding block rather than the marker line. + let lastIsolatedTarget: { start: number; end: number } | null = null; marked.walkTokens(tokens, (token) => { const found = content.indexOf(token.raw, searchFrom); @@ -307,34 +340,49 @@ const findBlocks = ( return; } searchFrom = found; + const rawEnd = found + token.raw.length; + const match = BLOCK_REFERENCE_REGEX.exec(token.raw); - if (!CAN_INCLUDE_BLOCK_REFERENCE.includes(token.type) || !match) { - return; - } - const id = match[1]; - if (!id) { - return; + if (match && CAN_INCLUDE_BLOCK_REFERENCE.includes(token.type)) { + const id = match[1]; + if (id) { + const markerStart = found + match.index; + const markerEnd = stripTrailingEol(content, rawEnd, found); + let contentStart = found; + let contentEnd = markerStart; + let isolated = false; + if (contentStart === contentEnd && lastIsolatedTarget) { + // Nothing precedes the `^id` on its line: it targets the block above. + contentStart = lastIsolatedTarget.start; + contentEnd = lastIsolatedTarget.end; + isolated = true; + } + const section = sectionContaining(root, abs(contentStart)); + const block: BlockNode = { + id, + kind: token.type, + isolated, + content: { start: abs(contentStart), end: abs(contentEnd) }, + marker: { start: abs(markerStart), end: abs(markerEnd) }, + trailingGap: { start: abs(markerEnd), end: abs(markerEnd) }, + section, + }; + if (token.type === "table") { + block.columns = (token as marked.Tokens.Table).header.map( + (cell) => cell.text + ); + } + blocks.push(block); + section.blocks.push(block); + } } - const contentStart = found; - const contentEnd = found + match.index; - const markerStart = found + match.index; - const markerEnd = found + token.raw.length; - const section = sectionContaining(root, abs(contentStart)); - const block: BlockNode = { - id, - kind: token.type, - content: { start: abs(contentStart), end: abs(contentEnd) }, - marker: { start: abs(markerStart), end: abs(markerEnd) }, - trailingGap: { start: abs(markerEnd), end: abs(markerEnd) }, - section, - }; - if (token.type === "table") { - block.columns = (token as marked.Tokens.Table).header.map( - (cell) => cell.text - ); + + if (TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE.includes(token.type)) { + lastIsolatedTarget = { + start: found, + end: stripTrailingEol(content, rawEnd, found), + }; } - blocks.push(block); - section.blocks.push(block); }); return blocks; From 0dce2b0acddee7e7e827b80f96d3f871f178f3f5 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 10:39:53 -0500 Subject: [PATCH 05/73] Freeze Obsidian conformance goldens and assert block spans exactly Capture the golden metadataCache parse for all eight conformance fixtures from live Obsidian and commit them beside the fixtures, flipping the suite's nine pending todos into real cross-checks. The model now agrees with Obsidian on every heading offset/level/text, block id, and block span. Tighten conformance.test.ts from a loose containment tolerance to exact block span equality, using BlockNode.isolated to pick Obsidian's single span: content-through-marker for an inline `^id`, the preceding block for an isolated one. Document both capture methods (DevTools snippet and a scriptable temporary REST route) in the conformance README. Co-Authored-By: Claude Fable 5 --- src/tests/conformance.test.ts | 11 ++- src/tests/conformance/README.md | 30 +++++++-- .../conformance/block-kinds.obsidian.json | 65 ++++++++++++++++++ .../code-fence-heading.obsidian.json | 50 ++++++++++++++ src/tests/conformance/crlf.obsidian.json | 45 +++++++++++++ .../duplicate-headings.obsidian.json | 67 +++++++++++++++++++ .../conformance/empty-heading.obsidian.json | 56 ++++++++++++++++ .../conformance/nested-lists.obsidian.json | 65 ++++++++++++++++++ .../conformance/skipped-levels.obsidian.json | 56 ++++++++++++++++ src/tests/conformance/table-eof.obsidian.json | 29 ++++++++ 10 files changed, 467 insertions(+), 7 deletions(-) create mode 100644 src/tests/conformance/block-kinds.obsidian.json create mode 100644 src/tests/conformance/code-fence-heading.obsidian.json create mode 100644 src/tests/conformance/crlf.obsidian.json create mode 100644 src/tests/conformance/duplicate-headings.obsidian.json create mode 100644 src/tests/conformance/empty-heading.obsidian.json create mode 100644 src/tests/conformance/nested-lists.obsidian.json create mode 100644 src/tests/conformance/skipped-levels.obsidian.json create mode 100644 src/tests/conformance/table-eof.obsidian.json diff --git a/src/tests/conformance.test.ts b/src/tests/conformance.test.ts index 1621766..6966baa 100644 --- a/src/tests/conformance.test.ts +++ b/src/tests/conformance.test.ts @@ -92,14 +92,19 @@ describe("Obsidian conformance", () => { expect([...blockIds(text)].sort()).toEqual(Object.keys(g.blocks).sort()); }); - test("each model block falls within Obsidian's block span", () => { + test("each model block span equals Obsidian's block span", () => { const model = buildModel(text); eachSection(model.root, (node) => { for (const block of node.blocks) { const span = g.blocks[block.id]; expect(span).toBeDefined(); - expect(block.content.start).toBeGreaterThanOrEqual(span.start); - expect(block.marker.end).toBeLessThanOrEqual(span.end + 1); + // Obsidian reports a single span per block: for an isolated `^id` it + // is the preceding block (our `content`); for an inline `^id` it runs + // from the content start through the marker. + const modelSpan = block.isolated + ? { start: block.content.start, end: block.content.end } + : { start: block.content.start, end: block.marker.end }; + expect(modelSpan).toEqual({ start: span.start, end: span.end }); } }); }); diff --git a/src/tests/conformance/README.md b/src/tests/conformance/README.md index ad8a7cd..2b6b7e0 100644 --- a/src/tests/conformance/README.md +++ b/src/tests/conformance/README.md @@ -10,14 +10,20 @@ boundaries. - Each `*.md` fixture has a frozen golden `*.obsidian.json` beside it, captured from live Obsidian (see below). - `conformance.test.ts` builds the model for each fixture and asserts that the - heading levels/texts/offsets and the block id set match the golden. + heading levels/texts/offsets, the block id set, and each block's span match + the golden exactly. Block spans encode Obsidian's rules directly: offsets + index raw bytes (a CRLF counts as two), spans exclude trailing newlines, and + an isolated `^id` on its own line takes the span of the block above it. - Fixtures **without** a golden are reported as pending (the suite stays green), so the capture step can lag behind adding a fixture. ## Capturing / refreshing goldens The goldens are only regenerated when fixtures change — this needs a live -Obsidian instance and is a manual, occasional step: +Obsidian instance and is a manual, occasional step. Either method below +produces the same golden shape. + +### Method A — DevTools console snippet (no plugin changes) 1. Copy this folder's `*.md` files into a `conformance/` folder at the root of any Obsidian vault. @@ -28,5 +34,21 @@ Obsidian instance and is a manual, occasional step: the vault. 4. Copy the resulting `*.obsidian.json` files back into this folder. -This is throwaway capture tooling: it is not part of the shipped library and -uses no plugin API. +### Method B — temporary REST route (fully scriptable, no console) + +With a live Obsidian running the sibling `obsidian-local-rest-api` plugin from +source (it rebuilds on save), a coding agent can refresh goldens end to end: + +1. Add a throwaway `GET /__debug_cache/*` route to that plugin's + `src/requestHandler.ts` that resolves the path with `getAbstractFileByPath` + and returns `{ content, cache: metadataCache.getFileCache(file) }` as JSON. +2. Wait a few seconds for the rebuild, then for each fixture `PUT /vault/...` + its exact bytes, poll the `note+json` endpoint until indexed, and + `GET /__debug_cache/...`. +3. Reduce each raw cache to the golden shape (`headings`/`sections`/`blocks`/ + `listItems` with `position.*.offset` start/end) and write it here. +4. **Delete the temporary route and the fixtures you PUT into the vault; do not + commit the route.** + +Both methods are throwaway capture tooling: neither is part of the shipped +library. diff --git a/src/tests/conformance/block-kinds.obsidian.json b/src/tests/conformance/block-kinds.obsidian.json new file mode 100644 index 0000000..c87c698 --- /dev/null +++ b/src/tests/conformance/block-kinds.obsidian.json @@ -0,0 +1,65 @@ +{ + "headings": [ + { + "level": 1, + "text": "Blocks", + "start": 0, + "end": 8 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 8 + }, + { + "type": "paragraph", + "start": 10, + "end": 60 + }, + { + "type": "list", + "start": 62, + "end": 93 + }, + { + "type": "table", + "start": 103, + "end": 156 + }, + { + "type": "blockquote", + "start": 167, + "end": 216 + } + ], + "blocks": { + "para1": { + "start": 10, + "end": 60 + }, + "list1": { + "start": 62, + "end": 93 + }, + "table1": { + "start": 103, + "end": 156 + }, + "quote1": { + "start": 167, + "end": 216 + } + }, + "listItems": [ + { + "start": 62, + "end": 77 + }, + { + "start": 78, + "end": 93 + } + ] +} diff --git a/src/tests/conformance/code-fence-heading.obsidian.json b/src/tests/conformance/code-fence-heading.obsidian.json new file mode 100644 index 0000000..ce4060c --- /dev/null +++ b/src/tests/conformance/code-fence-heading.obsidian.json @@ -0,0 +1,50 @@ +{ + "headings": [ + { + "level": 1, + "text": "Guide", + "start": 0, + "end": 7 + }, + { + "level": 2, + "text": "Real Subsection", + "start": 115, + "end": 133 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 7 + }, + { + "type": "paragraph", + "start": 9, + "end": 25 + }, + { + "type": "code", + "start": 27, + "end": 85 + }, + { + "type": "paragraph", + "start": 87, + "end": 113 + }, + { + "type": "heading", + "start": 115, + "end": 133 + }, + { + "type": "paragraph", + "start": 135, + "end": 163 + } + ], + "blocks": {}, + "listItems": [] +} diff --git a/src/tests/conformance/crlf.obsidian.json b/src/tests/conformance/crlf.obsidian.json new file mode 100644 index 0000000..50dcb1e --- /dev/null +++ b/src/tests/conformance/crlf.obsidian.json @@ -0,0 +1,45 @@ +{ + "headings": [ + { + "level": 1, + "text": "Title", + "start": 0, + "end": 7 + }, + { + "level": 2, + "text": "Sub", + "start": 41, + "end": 47 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 7 + }, + { + "type": "paragraph", + "start": 11, + "end": 37 + }, + { + "type": "heading", + "start": 41, + "end": 47 + }, + { + "type": "paragraph", + "start": 51, + "end": 75 + } + ], + "blocks": { + "b1": { + "start": 51, + "end": 75 + } + }, + "listItems": [] +} diff --git a/src/tests/conformance/duplicate-headings.obsidian.json b/src/tests/conformance/duplicate-headings.obsidian.json new file mode 100644 index 0000000..ea2477b --- /dev/null +++ b/src/tests/conformance/duplicate-headings.obsidian.json @@ -0,0 +1,67 @@ +{ + "headings": [ + { + "level": 1, + "text": "Log", + "start": 0, + "end": 5 + }, + { + "level": 2, + "text": "2026-07-18", + "start": 7, + "end": 20 + }, + { + "level": 2, + "text": "2026-07-18", + "start": 41, + "end": 54 + }, + { + "level": 2, + "text": "2026-07-19", + "start": 98, + "end": 111 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 5 + }, + { + "type": "heading", + "start": 7, + "end": 20 + }, + { + "type": "paragraph", + "start": 22, + "end": 39 + }, + { + "type": "heading", + "start": 41, + "end": 54 + }, + { + "type": "paragraph", + "start": 56, + "end": 96 + }, + { + "type": "heading", + "start": 98, + "end": 111 + }, + { + "type": "paragraph", + "start": 113, + "end": 125 + } + ], + "blocks": {}, + "listItems": [] +} diff --git a/src/tests/conformance/empty-heading.obsidian.json b/src/tests/conformance/empty-heading.obsidian.json new file mode 100644 index 0000000..ab3b730 --- /dev/null +++ b/src/tests/conformance/empty-heading.obsidian.json @@ -0,0 +1,56 @@ +{ + "headings": [ + { + "level": 1, + "text": "", + "start": 0, + "end": 2 + }, + { + "level": 2, + "text": "Named", + "start": 39, + "end": 47 + }, + { + "level": 2, + "text": "", + "start": 81, + "end": 83 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 2 + }, + { + "type": "paragraph", + "start": 4, + "end": 37 + }, + { + "type": "heading", + "start": 39, + "end": 47 + }, + { + "type": "paragraph", + "start": 49, + "end": 79 + }, + { + "type": "heading", + "start": 81, + "end": 83 + }, + { + "type": "paragraph", + "start": 85, + "end": 121 + } + ], + "blocks": {}, + "listItems": [] +} diff --git a/src/tests/conformance/nested-lists.obsidian.json b/src/tests/conformance/nested-lists.obsidian.json new file mode 100644 index 0000000..86eae52 --- /dev/null +++ b/src/tests/conformance/nested-lists.obsidian.json @@ -0,0 +1,65 @@ +{ + "headings": [ + { + "level": 1, + "text": "Tasks", + "start": 0, + "end": 7 + }, + { + "level": 2, + "text": "Done", + "start": 106, + "end": 113 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 7 + }, + { + "type": "list", + "start": 9, + "end": 104 + }, + { + "type": "heading", + "start": 106, + "end": 113 + }, + { + "type": "list", + "start": 115, + "end": 130 + } + ], + "blocks": {}, + "listItems": [ + { + "start": 9, + "end": 19 + }, + { + "start": 20, + "end": 66 + }, + { + "start": 68, + "end": 80 + }, + { + "start": 82, + "end": 92 + }, + { + "start": 94, + "end": 104 + }, + { + "start": 115, + "end": 130 + } + ] +} diff --git a/src/tests/conformance/skipped-levels.obsidian.json b/src/tests/conformance/skipped-levels.obsidian.json new file mode 100644 index 0000000..66b9a9a --- /dev/null +++ b/src/tests/conformance/skipped-levels.obsidian.json @@ -0,0 +1,56 @@ +{ + "headings": [ + { + "level": 1, + "text": "Top", + "start": 0, + "end": 5 + }, + { + "level": 3, + "text": "Deep child", + "start": 25, + "end": 39 + }, + { + "level": 1, + "text": "Second Top", + "start": 90, + "end": 102 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 5 + }, + { + "type": "paragraph", + "start": 7, + "end": 23 + }, + { + "type": "heading", + "start": 25, + "end": 39 + }, + { + "type": "paragraph", + "start": 41, + "end": 88 + }, + { + "type": "heading", + "start": 90, + "end": 102 + }, + { + "type": "paragraph", + "start": 104, + "end": 123 + } + ], + "blocks": {}, + "listItems": [] +} diff --git a/src/tests/conformance/table-eof.obsidian.json b/src/tests/conformance/table-eof.obsidian.json new file mode 100644 index 0000000..9926c4e --- /dev/null +++ b/src/tests/conformance/table-eof.obsidian.json @@ -0,0 +1,29 @@ +{ + "headings": [ + { + "level": 1, + "text": "Journal", + "start": 29, + "end": 38 + } + ], + "sections": [ + { + "type": "yaml", + "start": 0, + "end": 27 + }, + { + "type": "heading", + "start": 29, + "end": 38 + }, + { + "type": "table", + "start": 40, + "end": 163 + } + ], + "blocks": {}, + "listItems": [] +} From 058b2d570ad9f890f2ba991c955975d177ca12ef Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 11:11:13 -0500 Subject: [PATCH 06/73] Add 2.0 instruction types and cell-validity matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the 2.0 patch engine's instruction surface as plain TypeScript discriminated unions: the operation × scope × target-type algebra, the null-padded HeadingAddress, the structured ParentSpec for moves, the PatchResult/Warning shapes, and an engine error hierarchy. VALID_CELLS encodes which operation×scope combinations are meaningful per target type; isValidCell/assertValidCell reject the dead cells (every parent cell on block/frontmatter, prepend/append @ parent, block prepend/append @ marker, frontmatter delete @ marker, ...) loudly. The unit test checks the guard against an independent hand-written truth table across all 48 cells. This is built alongside the untouched 1.x engine; a published Zod schema is a deliberate follow-on. Co-Authored-By: Claude Opus 4.8 --- src/instructions.ts | 258 +++++++++++++++++++++++++++++++++ src/tests/instructions.test.ts | 156 ++++++++++++++++++++ 2 files changed, 414 insertions(+) create mode 100644 src/instructions.ts create mode 100644 src/tests/instructions.test.ts diff --git a/src/instructions.ts b/src/instructions.ts new file mode 100644 index 0000000..ea65700 --- /dev/null +++ b/src/instructions.ts @@ -0,0 +1,258 @@ +/** + * Instruction shapes for the 2.0 patch engine. + * + * The engine is one algebra: an {@link Operation} applied to a {@link Scope} of + * a target node. The scope names the *value* being edited (`content` = the + * body, `marker` = the label, `markerAndContent` = the whole node/subtree, + * `parent` = the node's place in the tree); the operation says what happens to + * that value. Every scope value is a plain string except `parent`, whose value + * is a structured {@link ParentSpec}. + * + * These are plain TypeScript discriminated unions. A published Zod schema + * (from which obsidian-local-rest-api will derive its MCP and OpenAPI docs) is + * a deliberate follow-on; nothing here uses `any`. + */ + +export type Operation = "replace" | "prepend" | "append" | "delete"; +export type Scope = "content" | "marker" | "markerAndContent" | "parent"; +export type TargetType = "heading" | "block" | "frontmatter"; + +/** + * A heading address: a null-padded or collapsed array of heading texts, or + * `null`/`[]` for the document root. A collapsed array (no `null`s) matches by + * nesting like a 1.x path; `null` explicitly marks a skipped level and `""` a + * genuinely empty-text heading. Array length is the heading's level. + */ +export type HeadingAddress = (string | null)[] | null; + +/** Where a moved section lands relative to its new parent's children. */ +export type Place = + | "first" + | "last" + | { before: HeadingAddress } + | { after: HeadingAddress }; + +/** The value of a `replace @ parent` (move) instruction. */ +export interface ParentSpec { + /** The section's new parent, or `null`/`[]` to move to the document root. */ + parent: HeadingAddress; + /** The position among the new parent's children. */ + place: Place; +} + +interface BaseInstruction { + /** + * Optimistic-concurrency precondition: the `version` token from the map the + * caller planned against. When present and it does not equal the current + * document's version, the patch fails without modifying the document. + */ + ifMatch?: string; + /** Create the target (and any missing ancestors) when it does not exist. */ + createTargetIfMissing?: boolean; + /** Fail instead of applying when the value already appears in the target. */ + rejectIfContentPreexists?: boolean; +} + +interface HeadingTargeted extends BaseInstruction { + targetType: "heading"; + target: HeadingAddress; +} +interface BlockTargeted extends BaseInstruction { + targetType: "block"; + /** The bare block id, without the leading `^`. */ + target: string; +} +interface FrontmatterTargeted extends BaseInstruction { + targetType: "frontmatter"; + /** The top-level frontmatter key. */ + target: string; +} + +// --- Heading instructions ------------------------------------------------ + +/** + * `replace`/`prepend`/`append` on a heading's body, label, or whole subtree. + * String values carry heading levels *relative* to the edited span's container + * (see `levels.ts`), so no `#` counting is required. + */ +export interface HeadingWriteInstruction extends HeadingTargeted { + operation: "replace" | "prepend" | "append"; + scope: "content" | "marker" | "markerAndContent"; + content: string; +} +/** `replace @ parent`: move (and re-level) the section beneath a new parent. */ +export interface HeadingMoveInstruction extends HeadingTargeted { + operation: "replace"; + scope: "parent"; + value: ParentSpec; +} +/** + * `delete` a heading's body (`content`), its subtree (`markerAndContent`), or + * the heading line only (`marker`) — the last dissolving the section into what + * textually precedes it. + */ +export interface HeadingDeleteInstruction extends HeadingTargeted { + operation: "delete"; + scope: "content" | "marker" | "markerAndContent"; +} +export type HeadingInstruction = + | HeadingWriteInstruction + | HeadingMoveInstruction + | HeadingDeleteInstruction; + +// --- Block instructions -------------------------------------------------- + +/** `replace`/`prepend`/`append` on a block's text or the whole block. */ +export interface BlockWriteInstruction extends BlockTargeted { + operation: "replace" | "prepend" | "append"; + scope: "content" | "markerAndContent"; + content: string; +} +/** `replace @ marker`: change the block's `^id`. */ +export interface BlockMarkerReplaceInstruction extends BlockTargeted { + operation: "replace"; + scope: "marker"; + /** The new block id, without the leading `^`. */ + content: string; +} +/** + * `delete` a block's text (`content`), the whole block (`markerAndContent`), or + * just the `^id` (`marker`, detaching the id while keeping the content). + */ +export interface BlockDeleteInstruction extends BlockTargeted { + operation: "delete"; + scope: "content" | "marker" | "markerAndContent"; +} +export type BlockInstruction = + | BlockWriteInstruction + | BlockMarkerReplaceInstruction + | BlockDeleteInstruction; + +// --- Frontmatter instructions -------------------------------------------- + +/** + * `replace`/`prepend`/`append` a frontmatter value (`content`) or whole entry + * (`markerAndContent`). `prepend`/`append` merge (list concat, dict merge, + * string concat). Values are JSON, never relative markdown. + */ +export interface FrontmatterValueInstruction extends FrontmatterTargeted { + operation: "replace" | "prepend" | "append"; + scope: "content" | "markerAndContent"; + content: unknown; +} +/** `replace @ marker`: rename the frontmatter key. */ +export interface FrontmatterRenameInstruction extends FrontmatterTargeted { + operation: "replace"; + scope: "marker"; + /** The new key name. */ + content: string; +} +/** `delete` a frontmatter value (`content`) or the whole entry (`markerAndContent`). */ +export interface FrontmatterDeleteInstruction extends FrontmatterTargeted { + operation: "delete"; + scope: "content" | "markerAndContent"; +} +export type FrontmatterInstruction = + | FrontmatterValueInstruction + | FrontmatterRenameInstruction + | FrontmatterDeleteInstruction; + +export type Instruction = + | HeadingInstruction + | BlockInstruction + | FrontmatterInstruction; + +// --- Result and warnings ------------------------------------------------- + +export type WarningCode = "heading-depth-overflow"; + +export interface Warning { + code: WarningCode; + message: string; +} + +export interface PatchResult { + document: string; + warnings: Warning[]; +} + +// --- Cell validity ------------------------------------------------------- + +/** + * The valid cells of the algebra: for each target type and scope, the + * operations that mean something. Cells absent here (e.g. `prepend @ parent`, + * any `parent` cell on a block or frontmatter target) are dead and rejected. + */ +const VALID_CELLS: Record> = { + heading: { + content: ["replace", "prepend", "append", "delete"], + marker: ["replace", "prepend", "append", "delete"], + markerAndContent: ["replace", "prepend", "append", "delete"], + parent: ["replace"], + }, + block: { + content: ["replace", "prepend", "append", "delete"], + marker: ["replace", "delete"], + markerAndContent: ["replace", "prepend", "append", "delete"], + parent: [], + }, + frontmatter: { + content: ["replace", "prepend", "append", "delete"], + marker: ["replace"], + markerAndContent: ["replace", "prepend", "append", "delete"], + parent: [], + }, +}; + +/** True when `operation` is meaningful for `scope` on a `targetType` target. */ +export const isValidCell = ( + targetType: TargetType, + operation: Operation, + scope: Scope +): boolean => VALID_CELLS[targetType][scope].includes(operation); + +/** The tuple {@link assertValidCell} inspects — a structural subset of an Instruction. */ +export interface Cell { + targetType: TargetType; + operation: Operation; + scope: Scope; +} + +// --- Errors -------------------------------------------------------------- + +/** Base class for every failure the 2.0 engine raises. */ +export class EngineError extends Error { + constructor(message: string) { + super(message); + this.name = new.target.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** The requested operation×scope combination is not part of the algebra. */ +export class InvalidCellError extends EngineError { + constructor(public cell: Cell) { + super( + `Invalid instruction: ${cell.operation} @ ${cell.scope} is not a valid operation for a ${cell.targetType} target` + ); + } +} + +/** The instruction's target could not be resolved in the document. */ +export class TargetNotFoundError extends EngineError {} + +/** The `ifMatch` precondition did not match the current document version. */ +export class PreconditionFailedError extends EngineError {} + +/** `rejectIfContentPreexists` was set and the value already exists at the target. */ +export class ContentPreexistsError extends EngineError {} + +/** A frontmatter merge or type mismatch made the operation impossible. */ +export class MergeError extends EngineError {} + +/** Throw {@link InvalidCellError} unless the cell is part of the algebra. */ +export const assertValidCell = (cell: Cell): void => { + if (!isValidCell(cell.targetType, cell.operation, cell.scope)) { + throw new InvalidCellError(cell); + } +}; diff --git a/src/tests/instructions.test.ts b/src/tests/instructions.test.ts new file mode 100644 index 0000000..38fca6e --- /dev/null +++ b/src/tests/instructions.test.ts @@ -0,0 +1,156 @@ +import { + Operation, + Scope, + TargetType, + Instruction, + isValidCell, + assertValidCell, + InvalidCellError, +} from "../instructions"; + +const OPERATIONS: Operation[] = ["replace", "prepend", "append", "delete"]; +const SCOPES: Scope[] = ["content", "marker", "markerAndContent", "parent"]; +const TARGET_TYPES: TargetType[] = ["heading", "block", "frontmatter"]; + +// An independent, hand-written truth table for the 4×4×3 matrix, so the guard +// is checked against an explicit spec rather than its own data structure. +const VALID = new Set([ + // heading + "heading|replace|content", + "heading|prepend|content", + "heading|append|content", + "heading|delete|content", + "heading|replace|marker", + "heading|prepend|marker", + "heading|append|marker", + "heading|delete|marker", + "heading|replace|markerAndContent", + "heading|prepend|markerAndContent", + "heading|append|markerAndContent", + "heading|delete|markerAndContent", + "heading|replace|parent", + // block + "block|replace|content", + "block|prepend|content", + "block|append|content", + "block|delete|content", + "block|replace|marker", + "block|delete|marker", + "block|replace|markerAndContent", + "block|prepend|markerAndContent", + "block|append|markerAndContent", + "block|delete|markerAndContent", + // frontmatter + "frontmatter|replace|content", + "frontmatter|prepend|content", + "frontmatter|append|content", + "frontmatter|delete|content", + "frontmatter|replace|marker", + "frontmatter|replace|markerAndContent", + "frontmatter|prepend|markerAndContent", + "frontmatter|append|markerAndContent", + "frontmatter|delete|markerAndContent", +]); + +describe("cell validity matrix", () => { + for (const targetType of TARGET_TYPES) { + for (const operation of OPERATIONS) { + for (const scope of SCOPES) { + const key = `${targetType}|${operation}|${scope}`; + const expected = VALID.has(key); + test(`${key} is ${expected ? "valid" : "invalid"}`, () => { + expect(isValidCell(targetType, operation, scope)).toBe(expected); + }); + } + } + } +}); + +describe("assertValidCell", () => { + test("passes for a valid cell", () => { + expect(() => + assertValidCell({ + targetType: "heading", + operation: "replace", + scope: "parent", + }) + ).not.toThrow(); + }); + + test("throws InvalidCellError for an invalid cell", () => { + expect(() => + assertValidCell({ + targetType: "heading", + operation: "prepend", + scope: "parent", + }) + ).toThrow(InvalidCellError); + }); + + test("rejects parent scope on block and frontmatter targets", () => { + expect(() => + assertValidCell({ + targetType: "block", + operation: "replace", + scope: "parent", + }) + ).toThrow(InvalidCellError); + expect(() => + assertValidCell({ + targetType: "frontmatter", + operation: "replace", + scope: "parent", + }) + ).toThrow(InvalidCellError); + }); +}); + +describe("Instruction typing (compile-time)", () => { + // These literals must type-check; they double as documentation of the shape. + test("representative instructions are assignable to Instruction", () => { + const examples: Instruction[] = [ + { + targetType: "heading", + operation: "append", + scope: "content", + target: ["Overview"], + content: "# A child section\n", + }, + { + targetType: "heading", + operation: "replace", + scope: "parent", + target: ["Overview", "Details"], + value: { parent: ["Appendix"], place: "last" }, + }, + { + targetType: "heading", + operation: "delete", + scope: "marker", + target: ["Overview", "Obsolete"], + }, + { + targetType: "block", + operation: "replace", + scope: "marker", + target: "thesis", + content: "revised-thesis", + }, + { + targetType: "frontmatter", + operation: "append", + scope: "content", + target: "reviewers", + content: ["alice"], + }, + { + targetType: "frontmatter", + operation: "replace", + scope: "marker", + target: "status", + content: "state", + }, + ]; + expect(examples).toHaveLength(6); + }); +}); From a1b99ee79886af64aa4525dffd5fbc77fda93a7d Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 11:15:08 -0500 Subject: [PATCH 07/73] Add the 2.0 address resolver resolveTarget turns a public target address back into the model node it names, which every engine cell needs. Headings match by their null-padded address in two tiers: an exact tier where an explicitly-levelled address selects a precise depth (disambiguating duplicates), and a collapsed fallback that matches by nesting so a plain path still resolves across skipped heading levels. Empty-text ("") headings stay distinct from skipped (null) levels, and duplicates resolve to the first match in document order. Blocks resolve by bare id and frontmatter by key. headingPath is promoted to an export of projection.ts so the map projection and the resolver share one definition of a node's padded address. Co-Authored-By: Claude Opus 4.8 --- src/projection.ts | 9 ++- src/resolve.ts | 125 ++++++++++++++++++++++++++++++++++++++ src/tests/resolve.test.ts | 125 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 src/resolve.ts create mode 100644 src/tests/resolve.test.ts diff --git a/src/projection.ts b/src/projection.ts index c6c6dde..2923350 100644 --- a/src/projection.ts +++ b/src/projection.ts @@ -21,7 +21,14 @@ export interface PublicMap { blocks: string[]; } -const headingPath = (node: SectionNode): (string | null)[] => { +/** + * The null-padded address of a heading-bearing section: an array whose length + * is the heading's level, index `i` holding the text of the level-`i+1` heading + * on the path to this node, `null` for a skipped level and `""` for an + * empty-text heading. Shared with the resolver so map addresses and target + * matching use one definition. + */ +export const headingPath = (node: SectionNode): (string | null)[] => { const level = node.heading!.level; const path: (string | null)[] = new Array(level).fill(null); let current: SectionNode | null = node; diff --git a/src/resolve.ts b/src/resolve.ts new file mode 100644 index 0000000..f17fd8e --- /dev/null +++ b/src/resolve.ts @@ -0,0 +1,125 @@ +/** + * Turn a public target address back into the model node it names. Headings are + * matched by their null-padded address (see {@link headingPath}); a target with + * explicit levels wins over a level-agnostic collapsed one, and a collapsed + * address falls back to matching by nesting so the common "the section named X" + * case needs no level annotation. Duplicates resolve to the first match in + * document order; the `ifMatch` precondition guards against staleness. + */ + +import { + BlockNode, + DocumentModel, + FrontmatterEntry, + SectionNode, + eachSection, +} from "./model.js"; +import { headingPath } from "./projection.js"; +import { HeadingAddress, TargetType } from "./instructions.js"; + +export type ResolvedTarget = + | { kind: "heading"; section: SectionNode } + | { kind: "block"; block: BlockNode } + | { kind: "frontmatter"; entry: FrontmatterEntry }; + +const arrayEquals = (a: (string | null)[], b: (string | null)[]): boolean => + a.length === b.length && a.every((value, index) => value === b[index]); + +/** Drop skipped levels, keeping empty-text (`""`) segments. */ +const collapse = (path: (string | null)[]): (string | null)[] => + path.filter((segment) => segment !== null); + +/** Every heading-bearing section, in document order. */ +const headingSections = (model: DocumentModel): SectionNode[] => { + const sections: SectionNode[] = []; + eachSection(model.root, (node) => { + if (node.heading) { + sections.push(node); + } + }); + return sections; +}; + +/** Resolve a heading address to its section (root for `null`/`[]`), or `null`. */ +export const resolveHeading = ( + model: DocumentModel, + address: HeadingAddress +): { kind: "heading"; section: SectionNode } | null => { + const target = address ?? []; + if (target.length === 0) { + return { kind: "heading", section: model.root }; + } + const sections = headingSections(model); + + // Exact tier: the node's padded address equals the target as written, so an + // explicitly levelled address (`["A", null, "B"]`) selects a precise depth. + const exact = sections.find((section) => + arrayEquals(headingPath(section), target) + ); + if (exact) { + return { kind: "heading", section: exact }; + } + + // Collapsed tier: match by nesting, ignoring levels, so a plain path still + // finds a section reached across a skipped heading level. + const wanted = collapse(target); + const collapsed = sections.find((section) => + arrayEquals(collapse(headingPath(section)), wanted) + ); + return collapsed ? { kind: "heading", section: collapsed } : null; +}; + +/** Resolve a bare block id to its block node, or `null`. */ +export const resolveBlock = ( + model: DocumentModel, + id: string +): { kind: "block"; block: BlockNode } | null => { + let found: BlockNode | null = null; + eachSection(model.root, (node) => { + if (found) { + return; + } + const block = node.blocks.find((candidate) => candidate.id === id); + if (block) { + found = block; + } + }); + return found ? { kind: "block", block: found } : null; +}; + +/** Resolve a frontmatter key to its entry, or `null`. */ +export const resolveFrontmatter = ( + model: DocumentModel, + key: string +): { kind: "frontmatter"; entry: FrontmatterEntry } | null => { + const entry = model.frontmatter.entries.find((candidate) => candidate.key === key); + return entry ? { kind: "frontmatter", entry } : null; +}; + +/** The addressing subset of an instruction the resolver needs. */ +export type Addressed = + | { targetType: "heading"; target: HeadingAddress } + | { targetType: "block"; target: string } + | { targetType: "frontmatter"; target: string }; + +/** Dispatch to the target-type-specific resolver. */ +export const resolveTarget = ( + model: DocumentModel, + instruction: Addressed +): ResolvedTarget | null => { + switch (instruction.targetType) { + case "heading": + return resolveHeading(model, instruction.target); + case "block": + return resolveBlock(model, instruction.target); + case "frontmatter": + return resolveFrontmatter(model, instruction.target); + } +}; + +/** The target types the resolver understands (kept in sync with the union). */ +export const RESOLVABLE_TARGET_TYPES: readonly TargetType[] = [ + "heading", + "block", + "frontmatter", +]; diff --git a/src/tests/resolve.test.ts b/src/tests/resolve.test.ts new file mode 100644 index 0000000..f9aff5b --- /dev/null +++ b/src/tests/resolve.test.ts @@ -0,0 +1,125 @@ +import { buildModel } from "../model"; +import { resolveTarget, resolveHeading, ResolvedTarget } from "../resolve"; + +const headingLevel = (r: ResolvedTarget | null): number | null => + r && r.kind === "heading" && r.section.heading ? r.section.heading.level : null; + +const bodyOf = (doc: string, r: ResolvedTarget | null): string => { + if (!r || r.kind !== "heading") return ""; + return doc.slice(r.section.content.start, r.section.content.end); +}; + +describe("resolveHeading", () => { + // Two h1 "A"s: the first has an h2 "B", the second an h3 "B" (skipped h2). + const dupDoc = ["# A", "## B", "body b1", "", "# A", "### B", "body b2", ""].join( + "\n" + ); + + test("null and [] resolve to the document root", () => { + const model = buildModel(dupDoc); + expect(resolveHeading(model, null)?.section).toBe(model.root); + expect(resolveHeading(model, [])?.section).toBe(model.root); + }); + + test("collapsed path matches by nesting", () => { + const model = buildModel(dupDoc); + // ["A","B"] exactly matches the h2 B (padded ["A","B"]), not the h3 B. + const r = resolveHeading(model, ["A", "B"]); + expect(headingLevel(r)).toBe(2); + expect(bodyOf(dupDoc, r)).toContain("body b1"); + }); + + test("null-padding disambiguates by level", () => { + const model = buildModel(dupDoc); + const r = resolveHeading(model, ["A", null, "B"]); + expect(headingLevel(r)).toBe(3); + expect(bodyOf(dupDoc, r)).toContain("body b2"); + }); + + test("duplicate headings resolve to the first in document order", () => { + const model = buildModel(dupDoc); + const r = resolveHeading(model, ["A"]); + expect(headingLevel(r)).toBe(1); + // The first "A" owns the h2 B subtree; its direct body is empty, but the + // node identity is the first occurrence. + expect(r?.kind).toBe("heading"); + if (r?.kind === "heading") { + expect(r.section).toBe(model.root.children[0]); + } + }); + + test("collapsed path spans a skipped level (garden path)", () => { + const doc = ["# Over", "### Quirk", "x", ""].join("\n"); + const model = buildModel(doc); + expect(headingLevel(resolveHeading(model, ["Over", "Quirk"]))).toBe(3); + expect(headingLevel(resolveHeading(model, ["Over", null, "Quirk"]))).toBe(3); + }); + + test("empty-text heading is distinct from a skipped level", () => { + const doc = ["# ", "under empty", "## Real", "deep", ""].join("\n"); + const model = buildModel(doc); + expect(headingLevel(resolveHeading(model, ["", "Real"]))).toBe(2); + // [null,"Real"] must NOT match ["","Real"]. + expect(resolveHeading(model, [null, "Real"])).toBeNull(); + }); + + test("returns null when no heading matches", () => { + const model = buildModel(dupDoc); + expect(resolveHeading(model, ["Nope"])).toBeNull(); + expect(resolveHeading(model, ["A", "C"])).toBeNull(); + }); +}); + +describe("resolveTarget dispatch", () => { + const doc = [ + "---", + "status: draft", + "reviewers:", + " - alice", + "---", + "para with ref ^blk1", + "", + "# H", + "para ^blk2", + "", + ].join("\n"); + + test("resolves a block by bare id", () => { + const model = buildModel(doc); + const r = resolveTarget(model, { targetType: "block", target: "blk1" }); + expect(r?.kind).toBe("block"); + if (r?.kind === "block") { + expect(r.block.id).toBe("blk1"); + expect(r.block.kind).toBe("paragraph"); + } + }); + + test("resolves a frontmatter key", () => { + const model = buildModel(doc); + const r = resolveTarget(model, { + targetType: "frontmatter", + target: "status", + }); + expect(r?.kind).toBe("frontmatter"); + if (r?.kind === "frontmatter") { + expect(r.entry.key).toBe("status"); + expect(r.entry.value).toBe("draft"); + } + }); + + test("resolves a heading via the dispatcher", () => { + const model = buildModel(doc); + const r = resolveTarget(model, { targetType: "heading", target: ["H"] }); + expect(headingLevel(r)).toBe(1); + }); + + test("returns null for missing block and frontmatter targets", () => { + const model = buildModel(doc); + expect( + resolveTarget(model, { targetType: "block", target: "missing" }) + ).toBeNull(); + expect( + resolveTarget(model, { targetType: "frontmatter", target: "missing" }) + ).toBeNull(); + }); +}); From ccd388504553c592d145988af4f5ffddd0dcaa8d Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 11:17:51 -0500 Subject: [PATCH 08/73] Add the splice primitive and node range geometry applyEdits is the one document-mutating primitive: it applies non-overlapping range replacements in a single pass, stitching the untouched spans back together so regions outside an edit are byte-identical. Overlapping or inverted ranges throw. Because trailingGap is library-owned, every edit is an exact byte-range replacement with no newline heuristics. ranges.ts turns model nodes into the spans operations act on, encoding the content-vs-subtree distinction: subtreeContentRange spans a section and its descendants (contiguous in document order) excluding the final gap, while subtreeEnd includes the gap for clean deletes. A fixture-driven property test confirms splicing every node's own text back is identity. Co-Authored-By: Claude Opus 4.8 --- src/ranges.ts | 55 ++++++++++++++ src/splice.ts | 50 ++++++++++++ src/tests/splice.test.ts | 159 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 src/ranges.ts create mode 100644 src/splice.ts create mode 100644 src/tests/splice.test.ts diff --git a/src/ranges.ts b/src/ranges.ts new file mode 100644 index 0000000..ce9b32a --- /dev/null +++ b/src/ranges.ts @@ -0,0 +1,55 @@ +/** + * Range geometry over model nodes: turn a resolved node into the byte spans an + * operation acts on. The key distinction is that `content` acts on a section's + * *direct* body while `markerAndContent` (and moves and deletes) act on the + * whole *subtree*, which is contiguous in document order because a section's + * descendants immediately follow its content and gap. + */ + +import { DocumentRange } from "./types.js"; +import { BlockNode, SectionNode } from "./model.js"; + +/** The deepest, last node within a section's subtree (the section itself if a leaf). */ +export const lastDescendant = (section: SectionNode): SectionNode => + section.children.length > 0 + ? lastDescendant(section.children[section.children.length - 1]) + : section; + +const subtreeStart = (section: SectionNode): number => + section.marker ? section.marker.start : section.content.start; + +/** + * The subtree's visible extent: the heading line through the last descendant's + * content, excluding the final separator gap. Used for whole-section replace + * and sibling-insert anchoring. + */ +export const subtreeContentRange = (section: SectionNode): DocumentRange => ({ + start: subtreeStart(section), + end: lastDescendant(section).trailingGap.start, +}); + +/** The subtree's end including its trailing separator gap; used for clean deletes. */ +export const subtreeEnd = (section: SectionNode): number => + lastDescendant(section).trailingGap.end; + +export class RootHasNoMarkerError extends Error {} + +/** The heading label line span; throws for the markerless document root. */ +export const headingMarkerRange = (section: SectionNode): DocumentRange => { + if (!section.marker) { + throw new RootHasNoMarkerError("the document root has no marker to address"); + } + return section.marker; +}; + +/** The block's addressable text (the preceding block, for an isolated `^id`). */ +export const blockContentRange = (block: BlockNode): DocumentRange => block.content; + +/** The `^id` token span. */ +export const blockMarkerRange = (block: BlockNode): DocumentRange => block.marker; + +/** The whole block: content through marker (they are contiguous for inline ids). */ +export const blockFullRange = (block: BlockNode): DocumentRange => ({ + start: Math.min(block.content.start, block.marker.start), + end: Math.max(block.content.end, block.marker.end), +}); diff --git a/src/splice.ts b/src/splice.ts new file mode 100644 index 0000000..dfce6eb --- /dev/null +++ b/src/splice.ts @@ -0,0 +1,50 @@ +/** + * The one primitive that mutates a document: apply a set of non-overlapping + * range replacements in a single pass. Because the model owns whitespace via + * `trailingGap`, every edit here is an exact byte-range replacement — there are + * no newline heuristics. Callers decide the ranges and text; this just stitches + * the untouched spans back together, so regions outside an edit are unchanged. + */ + +import { DocumentRange } from "./types.js"; + +export interface Edit { + range: DocumentRange; + text: string; +} + +export class OverlappingEditsError extends Error {} + +/** + * Apply `edits` to `document`. Edits may be given in any order and may be + * insertions (`start === end`); they must not overlap. Returns the new + * document. + */ +export const applyEdits = (document: string, edits: Edit[]): string => { + const sorted = [...edits].sort((a, b) => a.range.start - b.range.start); + const parts: string[] = []; + let cursor = 0; + for (const { range, text } of sorted) { + if (range.end < range.start) { + throw new OverlappingEditsError( + `inverted range [${range.start}, ${range.end})` + ); + } + if (range.start < cursor) { + throw new OverlappingEditsError( + `edit at [${range.start}, ${range.end}) overlaps a preceding edit ending at ${cursor}` + ); + } + parts.push(document.slice(cursor, range.start), text); + cursor = range.end; + } + parts.push(document.slice(cursor)); + return parts.join(""); +}; + +/** Replace a single range's text. */ +export const spliceRange = ( + document: string, + range: DocumentRange, + text: string +): string => applyEdits(document, [{ range, text }]); diff --git a/src/tests/splice.test.ts b/src/tests/splice.test.ts new file mode 100644 index 0000000..839bd2f --- /dev/null +++ b/src/tests/splice.test.ts @@ -0,0 +1,159 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { applyEdits, spliceRange, OverlappingEditsError } from "../splice"; +import { + buildModel, + eachSection, + SectionNode, +} from "../model"; +import { + lastDescendant, + subtreeContentRange, + subtreeEnd, + headingMarkerRange, + blockFullRange, + RootHasNoMarkerError, +} from "../ranges"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const CONFORMANCE_DIR = path.join(__dirname, "conformance"); + +describe("applyEdits", () => { + const doc = "0123456789"; + + test("replaces a single range", () => { + expect(spliceRange(doc, { start: 2, end: 5 }, "XYZ")).toBe("01XYZ56789"); + }); + + test("inserts at a point (empty range)", () => { + expect(spliceRange(doc, { start: 3, end: 3 }, "--")).toBe("012--3456789"); + }); + + test("stitches multiple non-overlapping edits regardless of order", () => { + const result = applyEdits(doc, [ + { range: { start: 7, end: 8 }, text: "B" }, + { range: { start: 1, end: 2 }, text: "A" }, + ]); + expect(result).toBe("0A23456B89"); + }); + + test("throws on overlapping edits", () => { + expect(() => + applyEdits(doc, [ + { range: { start: 2, end: 5 }, text: "x" }, + { range: { start: 4, end: 6 }, text: "y" }, + ]) + ).toThrow(OverlappingEditsError); + }); + + test("throws on an inverted range", () => { + expect(() => spliceRange(doc, { start: 5, end: 2 }, "x")).toThrow( + OverlappingEditsError + ); + }); +}); + +describe("splice no-op identity (property over fixtures)", () => { + const fixtures = fs + .readdirSync(CONFORMANCE_DIR) + .filter((f) => f.endsWith(".md")) + .map((f) => fs.readFileSync(path.join(CONFORMANCE_DIR, f), "utf-8")); + + test("splicing every node's own text back is identity", () => { + for (const doc of fixtures) { + const model = buildModel(doc); + const edits: Array<{ range: { start: number; end: number }; text: string }> = + []; + eachSection(model.root, (node) => { + if (node.marker) { + edits.push({ + range: node.marker, + text: doc.slice(node.marker.start, node.marker.end), + }); + } + edits.push({ + range: node.content, + text: doc.slice(node.content.start, node.content.end), + }); + }); + // Edits are node markers/contents, which are disjoint and in order. + expect(applyEdits(doc, edits)).toBe(doc); + } + }); +}); + +describe("subtree ranges", () => { + const doc = [ + "# A", // 0 + "a-body", // + "", // + "## B", // + "b-body", // + "", // + "# C", // + "c-body", // + "", // + ].join("\n"); + + const sectionByText = (text: string): SectionNode => { + let found: SectionNode | undefined; + eachSection(buildModel(doc).root, (n) => { + if (n.heading?.text === text) found = n; + }); + if (!found) throw new Error(`no section ${text}`); + return found; + }; + + test("lastDescendant walks to the deepest last child", () => { + const a = sectionByText("A"); + expect(lastDescendant(a).heading?.text).toBe("B"); + }); + + test("subtreeContentRange covers the section and its descendants but not the next sibling", () => { + const a = sectionByText("A"); + const span = doc.slice( + subtreeContentRange(a).start, + subtreeContentRange(a).end + ); + expect(span).toContain("# A"); + expect(span).toContain("a-body"); + expect(span).toContain("## B"); + expect(span).toContain("b-body"); + expect(span).not.toContain("# C"); + }); + + test("subtreeEnd includes the trailing separator gap", () => { + const a = sectionByText("A"); + expect(subtreeEnd(a)).toBeGreaterThan(subtreeContentRange(a).end); + // The gap between B's body and C is a single blank line. + expect(doc.slice(subtreeContentRange(a).end, subtreeEnd(a))).toBe("\n"); + }); + + test("headingMarkerRange returns the heading line and rejects the root", () => { + const a = sectionByText("A"); + expect(doc.slice(headingMarkerRange(a).start, headingMarkerRange(a).end)).toBe( + "# A\n" + ); + expect(() => headingMarkerRange(buildModel(doc).root)).toThrow( + RootHasNoMarkerError + ); + }); +}); + +describe("block ranges", () => { + test("blockFullRange spans an inline id's content through its marker", () => { + const doc = "a paragraph ^ref\n"; + const model = buildModel(doc); + let range: { start: number; end: number } | undefined; + eachSection(model.root, (n) => + n.blocks.forEach((b) => { + if (b.id === "ref") range = blockFullRange(b); + }) + ); + expect(range).toBeDefined(); + expect(doc.slice(range!.start, range!.end)).toBe("a paragraph ^ref"); + }); +}); From 7a4a007516328907faa676395390ac71a8708426 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 11:20:09 -0500 Subject: [PATCH 09/73] Add relative heading level rebasing rebaseHeadings rewrites each top-level heading's #-count in an inserted value by adding a baseline, turning relative levels into absolute ones on write: content scope uses the target's own level (a `#` becomes a direct child), markerAndContent and sibling inserts use the parent's level (`# Title` becomes a same-level sibling), and the document root uses baseline 0 so relative == absolute and whole-document writes stay byte-identical. Detection uses the marked lexer, so `#` lines inside fenced code are left alone. A rebased level past h6 is still written verbatim but reported as a heading-depth-overflow warning. The value is normalized to LF; the engine re-applies the document's line ending when it splices. Co-Authored-By: Claude Opus 4.8 --- src/levels.ts | 75 ++++++++++++++++++++++++++++++++++++++++ src/tests/levels.test.ts | 50 +++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/levels.ts create mode 100644 src/tests/levels.test.ts diff --git a/src/levels.ts b/src/levels.ts new file mode 100644 index 0000000..050cd80 --- /dev/null +++ b/src/levels.ts @@ -0,0 +1,75 @@ +/** + * Relative heading levels. A section value carries heading `#`-counts relative + * to the container of the span being written; on write they are rebased to + * absolute by adding a baseline: + * + * - `content` scope → baseline = the target section's own level (`#` = a direct + * child). + * - `markerAndContent` / sibling insert → baseline = the *parent's* level + * (`# Title` = a section at the target's own level). + * - document root → baseline 0, so relative == absolute and whole-document + * writes are byte-identical. + * + * This is well-founded because a section can never contain a heading at or above + * its own level, so stripping the container's level on read and re-adding it on + * write round-trips losslessly. A rebased level past h6 is still written (the + * `#`s go in verbatim) but is reported as a warning, since the "heading" will + * not be structurally addressable in the next map. + * + * The value is normalized to LF here; the engine re-applies the document's line + * ending when it splices the result in. + */ + +import * as marked from "marked"; + +import { applyEdits, Edit } from "./splice.js"; +import { Warning } from "./instructions.js"; + +const MAX_HEADING_LEVEL = 6; +const LEADING_HASHES = /^([ \t]{0,3})(#{1,})/; + +export interface RebaseResult { + text: string; + warnings: Warning[]; +} + +/** + * Rewrite each top-level heading's `#`-count in `value` by adding `baseline`, + * leaving `#` lines inside fenced code (and all other content) untouched. + */ +export const rebaseHeadings = (value: string, baseline: number): RebaseResult => { + const normalized = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (baseline === 0) { + // Nothing to shift; relative already equals absolute. + return { text: normalized, warnings: [] }; + } + + const tokens = new marked.Lexer().lex(normalized); + const edits: Edit[] = []; + const warnings: Warning[] = []; + let offset = 0; + for (const token of tokens) { + if (token.type === "heading") { + const match = LEADING_HASHES.exec(token.raw); + if (match) { + const hashStart = offset + match[1].length; + const hashEnd = hashStart + match[2].length; + const absoluteLevel = match[2].length + baseline; + edits.push({ + range: { start: hashStart, end: hashEnd }, + text: "#".repeat(absoluteLevel), + }); + if (absoluteLevel > MAX_HEADING_LEVEL) { + const heading = token as marked.Tokens.Heading; + warnings.push({ + code: "heading-depth-overflow", + message: `Heading "${heading.text.trim()}" resolves to level ${absoluteLevel}, beyond Markdown's maximum of ${MAX_HEADING_LEVEL}; it will not be structurally addressable.`, + }); + } + } + } + offset += token.raw.length; + } + + return { text: applyEdits(normalized, edits), warnings }; +}; diff --git a/src/tests/levels.test.ts b/src/tests/levels.test.ts new file mode 100644 index 0000000..9de5954 --- /dev/null +++ b/src/tests/levels.test.ts @@ -0,0 +1,50 @@ +import { rebaseHeadings } from "../levels"; + +describe("rebaseHeadings", () => { + test("baseline 0 leaves the value unchanged (root: relative == absolute)", () => { + const value = "## X\nbody\n"; + const result = rebaseHeadings(value, 0); + expect(result.text).toBe(value); + expect(result.warnings).toEqual([]); + }); + + test("content baseline adds the target's level (a `#` becomes a direct child)", () => { + const result = rebaseHeadings("# Child\ntext\n", 2); + expect(result.text).toBe("### Child\ntext\n"); + expect(result.warnings).toEqual([]); + }); + + test("markerAndContent baseline (parent level) makes `# Title` a same-level sibling", () => { + // Target is level 2, its parent is level 1, so baseline 1 -> `# Title` at level 2. + const result = rebaseHeadings("# Title\nbody\n", 1); + expect(result.text).toBe("## Title\nbody\n"); + }); + + test("rebases every heading in a multi-section fragment", () => { + const result = rebaseHeadings("# One\na\n# Two\nb\n", 1); + expect(result.text).toBe("## One\na\n## Two\nb\n"); + }); + + test("rebases nested headings preserving their relative depth", () => { + const result = rebaseHeadings("# Parent\n## Child\n", 2); + expect(result.text).toBe("### Parent\n#### Child\n"); + }); + + test("does not rebase `#` lines inside a fenced code block", () => { + const value = "# Real\n\n```\n# not a heading\n```\n"; + const result = rebaseHeadings(value, 1); + expect(result.text).toBe("## Real\n\n```\n# not a heading\n```\n"); + }); + + test("warns when a rebased heading exceeds h6 but still writes the hashes", () => { + const result = rebaseHeadings("### Deep\n", 5); // 3 + 5 = 8 + expect(result.text).toBe("######## Deep\n"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("heading-depth-overflow"); + }); + + test("normalizes CRLF in the value to LF so the engine can apply the document's ending", () => { + const result = rebaseHeadings("# Child\r\ntext\r\n", 1); + expect(result.text).toBe("## Child\ntext\n"); + }); +}); From f41a45cc375ed3854aafb8e5ea8bab857e24e024 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 11:34:01 -0500 Subject: [PATCH 10/73] Add the 2.0 engine core for plain-string write cells Introduce `patch(document, instruction)` and cover the write half of the algebra for heading and block targets: replace/prepend/append at content, marker, and markerAndContent scopes, plus block-id replacement. The engine builds the model, validates the requested cell via the matrix guard, enforces the optional `ifMatch` precondition against the document version, resolves the target, and dispatches to a per-scope handler that expresses each mutation as non-overlapping byte-range edits applied in a single splice. Heading-bearing fragments are rebased through `rebaseHeadings` with a scope-appropriate baseline (the section's own level for content, the parent's level for markerAndContent), inserted content is re-expressed in the document's line ending and terminated with a single ending, and depth-overflow warnings surface in the result. Structural cells (delete, move, dissolve), frontmatter cells, and createTargetIfMissing raise a clear not-yet-implemented error pending the following increments. Co-Authored-By: Claude Opus 4.8 --- src/engine.ts | 284 +++++++++++++++++++++++++++++++++++ src/tests/engine.test.ts | 313 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 597 insertions(+) create mode 100644 src/engine.ts create mode 100644 src/tests/engine.test.ts diff --git a/src/engine.ts b/src/engine.ts new file mode 100644 index 0000000..c576717 --- /dev/null +++ b/src/engine.ts @@ -0,0 +1,284 @@ +/** + * The 2.0 patch engine: `patch(document, instruction)` builds the model, + * validates the requested cell of the algebra, checks the `ifMatch` + * precondition, resolves the target node, and dispatches to the handler for the + * requested operation×scope. Every mutation is expressed as a set of + * non-overlapping byte-range edits and applied in one splice, so regions + * outside the edit are byte-preserved. + * + * This module covers the plain-string *write* cells (`replace`/`prepend`/ + * `append`) for heading and block targets. Structural cells (move, dissolve, + * delete), frontmatter cells, and target creation are layered on in sibling + * modules and later increments; until then those cells raise a clear error. + */ + +import { buildModel, DocumentModel, SectionNode, BlockNode } from "./model.js"; +import { resolveTarget } from "./resolve.js"; +import { rebaseHeadings } from "./levels.js"; +import { applyEdits, Edit } from "./splice.js"; +import { + headingMarkerRange, + subtreeContentRange, + subtreeEnd, + blockFullRange, +} from "./ranges.js"; +import { + Instruction, + HeadingInstruction, + BlockInstruction, + PatchResult, + Warning, + EngineError, + PreconditionFailedError, + TargetNotFoundError, + assertValidCell, +} from "./instructions.js"; + +/** The subset of an instruction {@link assertValidCell} inspects. */ +const cellOf = (instruction: Instruction) => ({ + targetType: instruction.targetType, + operation: instruction.operation, + scope: instruction.scope, +}); + +const normalizeToLf = (text: string): string => + text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + +/** Re-express `text` using the document's line ending. */ +const toLineEnding = (text: string, ending: "\n" | "\r\n"): string => + ending === "\n" + ? normalizeToLf(text) + : normalizeToLf(text).replace(/\n/g, "\r\n"); + +/** + * Normalize an inserted body/section fragment to end with exactly one line + * ending (or empty for an empty value), matching the model's invariant that a + * non-empty content span ends with a single terminator and the library owns the + * blank-line gap that follows. + */ +const endWithSingleEol = (text: string, ending: "\n" | "\r\n"): string => { + const stripped = text.replace(/(?:\r\n|\r|\n)+$/, ""); + return stripped.length === 0 ? "" : stripped + ending; +}; + +/** + * Turn a relative heading-bearing fragment into the exact bytes to splice in: + * rebase its `#`-levels by `baseline`, re-apply the document's line ending, and + * terminate it with a single ending. + */ +const sectionFragment = ( + value: string, + baseline: number, + model: DocumentModel +): { text: string; warnings: Warning[] } => { + const rebased = rebaseHeadings(value, baseline); + return { + text: endWithSingleEol(toLineEnding(rebased.text, model.lineEnding), model.lineEnding), + warnings: rebased.warnings, + }; +}; + +/** The parent's source heading level, or 0 when the parent is the root. */ +const parentLevel = (section: SectionNode): number => + section.parent?.heading?.level ?? 0; + +// --- Heading handlers ---------------------------------------------------- + +const patchHeading = ( + document: string, + model: DocumentModel, + instruction: HeadingInstruction, + section: SectionNode +): PatchResult => { + if (instruction.operation === "delete") { + throw new EngineError("heading delete is not yet implemented in this build"); + } + if (instruction.scope === "parent") { + throw new EngineError("heading move is not yet implemented in this build"); + } + // Excluding delete and parent narrows to HeadingWriteInstruction. + const { operation, scope, content: value } = instruction; + + if (scope === "content") { + const fragment = sectionFragment(value, section.heading?.level ?? 0, model); + const edit = contentEdit(section.content, operation, fragment.text); + return splice(document, [edit], fragment.warnings); + } + + if (scope === "marker") { + return splice(document, [markerRenameEdit(document, model, section, operation, value)], []); + } + + // markerAndContent: the whole subtree, rebased to the parent's level. + const fragment = sectionFragment(value, parentLevel(section), model); + if (operation === "replace") { + return splice( + document, + [{ range: subtreeContentRange(section), text: fragment.text }], + fragment.warnings + ); + } + if (!section.heading) { + throw new EngineError( + "cannot insert a sibling of the document root" + ); + } + // Sibling insert: before the subtree (prepend) or after it, past its gap + // (append), so the target keeps its separator from what already surrounds it. + const at = + operation === "prepend" + ? subtreeContentRange(section).start + : subtreeEnd(section); + return splice( + document, + [{ range: { start: at, end: at }, text: fragment.text }], + fragment.warnings + ); +}; + +/** Build the edit for a `content`-scope write on a body range. */ +const contentEdit = ( + content: { start: number; end: number }, + operation: "replace" | "prepend" | "append", + text: string +): Edit => { + switch (operation) { + case "replace": + return { range: content, text }; + case "prepend": + return { range: { start: content.start, end: content.start }, text }; + case "append": + return { range: { start: content.end, end: content.end }, text }; + } +}; + +/** Rebuild a heading line with renamed/prefixed/suffixed label text. */ +const markerRenameEdit = ( + document: string, + model: DocumentModel, + section: SectionNode, + operation: "replace" | "prepend" | "append", + value: string +): Edit => { + const range = headingMarkerRange(section); // throws for the root + const markerText = document.slice(range.start, range.end); + const hasEol = /(?:\r\n|\r|\n)$/.test(markerText); + const eol = hasEol ? model.lineEnding : ""; + const oldText = section.heading?.text ?? ""; + const newText = + operation === "replace" + ? value + : operation === "prepend" + ? value + oldText + : oldText + value; + const level = section.heading?.level ?? 1; + return { range, text: "#".repeat(level) + " " + newText + eol }; +}; + +// --- Block handlers ------------------------------------------------------ + +const patchBlock = ( + document: string, + model: DocumentModel, + instruction: BlockInstruction, + block: BlockNode +): PatchResult => { + if (instruction.operation === "delete") { + throw new EngineError("block delete is not yet implemented in this build"); + } + // Excluding delete narrows to BlockWrite | BlockMarkerReplace; both carry a + // string `content`. Block content and ids are literal, never rebased. + const { operation, scope } = instruction; + const value = toLineEnding(instruction.content, model.lineEnding); + + if (scope === "content") { + return splice(document, [contentEdit(block.content, operation, value)], []); + } + + if (scope === "marker") { + // replace only (guaranteed by the cell matrix): swap the id, keeping any + // surrounding whitespace and the `^` sigil. + const markerText = document.slice(block.marker.start, block.marker.end); + const text = markerText.replace(/\^[A-Za-z0-9_-]+/, "^" + instruction.content); + return splice(document, [{ range: block.marker, text }], []); + } + + // markerAndContent: the whole block. + const full = blockFullRange(block); + if (operation === "replace") { + return splice(document, [{ range: full, text: value }], []); + } + // Sibling block insert, separated by a blank line (markdown block boundary). + const separator = model.lineEnding + model.lineEnding; + if (operation === "prepend") { + return splice( + document, + [{ range: { start: full.start, end: full.start }, text: value + separator }], + [] + ); + } + return splice( + document, + [{ range: { start: full.end, end: full.end }, text: separator + value }], + [] + ); +}; + +// --- Entry point --------------------------------------------------------- + +const splice = ( + document: string, + edits: Edit[], + warnings: Warning[] +): PatchResult => ({ document: applyEdits(document, edits), warnings }); + +/** + * Apply a single {@link Instruction} to `document`, returning the new document + * and any warnings. Throws {@link PreconditionFailedError} on an `ifMatch` + * mismatch, {@link TargetNotFoundError} when the target does not resolve, and + * {@link InvalidCellError} for a combination outside the algebra. + */ +export const patch = ( + document: string, + instruction: Instruction +): PatchResult => { + const model = buildModel(document); + assertValidCell(cellOf(instruction)); + + if (instruction.ifMatch !== undefined && instruction.ifMatch !== model.version) { + throw new PreconditionFailedError( + `ifMatch precondition failed: expected version ${instruction.ifMatch}, document is at ${model.version}` + ); + } + + const resolved = resolveTarget(model, instruction); + if (!resolved) { + if (instruction.createTargetIfMissing) { + throw new EngineError( + "createTargetIfMissing is not yet implemented in this build" + ); + } + throw new TargetNotFoundError( + `could not resolve ${instruction.targetType} target ${JSON.stringify( + instruction.target + )}` + ); + } + + // `resolveTarget` dispatches on `targetType`, so the resolved kind always + // matches the instruction; narrow explicitly for the type system. + if (instruction.targetType === "heading" && resolved.kind === "heading") { + return patchHeading(document, model, instruction, resolved.section); + } + if (instruction.targetType === "block" && resolved.kind === "block") { + return patchBlock(document, model, instruction, resolved.block); + } + if (instruction.targetType === "frontmatter" && resolved.kind === "frontmatter") { + throw new EngineError( + "frontmatter patching is not yet implemented in this build" + ); + } + throw new EngineError( + `resolved ${resolved.kind} does not match ${instruction.targetType} target` + ); +}; diff --git a/src/tests/engine.test.ts b/src/tests/engine.test.ts new file mode 100644 index 0000000..910eca2 --- /dev/null +++ b/src/tests/engine.test.ts @@ -0,0 +1,313 @@ +import { patch } from "../engine"; +import { + PreconditionFailedError, + TargetNotFoundError, + Instruction, +} from "../instructions"; +import { RootHasNoMarkerError } from "../ranges"; + +// A small tree: A (h1) > B (h2), then C (h1), each with a one-line body and a +// library-owned blank-line gap between siblings. +const DOC = "# A\na-body\n\n## B\nb-body\n\n# C\nc-body\n"; + +describe("patch — heading content cells", () => { + test("replace @ content sets the section body, leaving the gap and siblings", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "content", + content: "new-a", + }); + expect(result.document).toBe( + "# A\nnew-a\n\n## B\nb-body\n\n# C\nc-body\n" + ); + expect(result.warnings).toEqual([]); + }); + + test("prepend @ content inserts at the top of the body", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "prepend", + scope: "content", + content: "top", + }); + expect(result.document).toBe( + "# A\ntop\na-body\n\n## B\nb-body\n\n# C\nc-body\n" + ); + }); + + test("append @ content inserts at the bottom of the body, before the gap", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "bot", + }); + expect(result.document).toBe( + "# A\na-body\nbot\n\n## B\nb-body\n\n# C\nc-body\n" + ); + }); + + test("content values carry heading levels relative to the section (a `#` becomes a child)", () => { + // Baseline is A's level (1), so a `#` heading in the value lands at level 2. + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "append", + scope: "content", + content: "# child of B", + }); + // B is level 2, so its baseline is 2 and `# child of B` becomes level 3. + expect(result.document).toContain("### child of B\n"); + }); + + test("a rebased heading past h6 still writes but surfaces a warning", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "content", + content: "##### deep", // level 5 + baseline 2 = 7 + }); + expect(result.document).toContain("####### deep\n"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("heading-depth-overflow"); + }); +}); + +describe("patch — heading marker cells", () => { + test("replace @ marker renames the heading, keeping its level", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "marker", + content: "Renamed", + }); + expect(result.document).toContain("## Renamed\n"); + expect(result.document).not.toContain("## B\n"); + }); + + test("prepend @ marker prefixes the label text literally", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "prepend", + scope: "marker", + content: "X-", + }); + expect(result.document).toContain("## X-B\n"); + }); + + test("append @ marker suffixes the label text literally", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "append", + scope: "marker", + content: "-Y", + }); + expect(result.document).toContain("## B-Y\n"); + }); + + test("marker ops on the document root are rejected (root has no heading line)", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: null, + operation: "replace", + scope: "marker", + content: "x", + }) + ).toThrow(RootHasNoMarkerError); + }); +}); + +describe("patch — heading markerAndContent cells", () => { + test("replace @ markerAndContent swaps the whole subtree, rebasing to the parent's level", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "markerAndContent", + content: "# NewB\nnb", // baseline is A's level (1) -> level 2 + }); + expect(result.document).toBe( + "# A\na-body\n\n## NewB\nnb\n\n# C\nc-body\n" + ); + }); + + test("prepend @ markerAndContent inserts a sibling section before the target", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "prepend", + scope: "markerAndContent", + content: "# Sib\nsb", + }); + expect(result.document).toBe( + "# A\na-body\n\n## Sib\nsb\n## B\nb-body\n\n# C\nc-body\n" + ); + }); + + test("append @ markerAndContent inserts a sibling section after the target's subtree", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "append", + scope: "markerAndContent", + content: "# Sib\nsb", + }); + expect(result.document).toBe( + "# A\na-body\n\n## B\nb-body\n\n## Sib\nsb\n# C\nc-body\n" + ); + }); +}); + +describe("patch — block cells", () => { + const BLOCK_DOC = "a paragraph ^ref\n"; + + test("replace @ content changes the block text, keeping its id", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "replace", + scope: "content", + content: "changed", + }); + expect(result.document).toBe("changed ^ref\n"); + }); + + test("prepend @ content inserts before the block text", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "prepend", + scope: "content", + content: "X ", + }); + expect(result.document).toBe("X a paragraph ^ref\n"); + }); + + test("append @ content inserts after the block text, before the id", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + content: "!", + }); + expect(result.document).toBe("a paragraph! ^ref\n"); + }); + + test("replace @ marker changes the block id, keeping the content", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "replace", + scope: "marker", + content: "newid", + }); + expect(result.document).toBe("a paragraph ^newid\n"); + }); + + test("replace @ markerAndContent swaps the whole block", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "replace", + scope: "markerAndContent", + content: "new stuff ^id2", + }); + expect(result.document).toBe("new stuff ^id2\n"); + }); + + test("append @ markerAndContent inserts a sibling block after, separated by a blank line", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "markerAndContent", + content: "new ^id2", + }); + expect(result.document).toBe("a paragraph ^ref\n\nnew ^id2\n"); + }); + + test("prepend @ markerAndContent inserts a sibling block before, separated by a blank line", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "prepend", + scope: "markerAndContent", + content: "new ^id2", + }); + expect(result.document).toBe("new ^id2\n\na paragraph ^ref\n"); + }); +}); + +describe("patch — preconditions and resolution", () => { + test("ifMatch matching the current version applies the patch", () => { + // Compute the version via a no-op resolve by patching with the right token. + const first = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "x", + }); + expect(first.document).toContain("a-body\nx\n"); + }); + + test("ifMatch not matching the current version fails without modifying the document", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "x", + ifMatch: "deadbeef", + }) + ).toThrow(PreconditionFailedError); + }); + + test("an unresolvable target raises TargetNotFoundError", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: ["Nope"], + operation: "replace", + scope: "content", + content: "x", + }) + ).toThrow(TargetNotFoundError); + }); +}); + +describe("patch — no-op identity", () => { + test("replacing a section body with its own current text is byte-identity", () => { + // A's body is "a-body\n"; replacing with the same text must not change bytes. + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "content", + content: "a-body", + }); + expect(result.document).toBe(DOC); + }); + + test("a valid instruction type-checks against the Instruction union", () => { + const instruction: Instruction = { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "content", + content: "x", + }; + expect(instruction.operation).toBe("replace"); + }); +}); From e7cf73861087f7405937a24534ace600c32b7ba1 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 11:43:16 -0500 Subject: [PATCH 11/73] Add the 2.0 engine's structural cells (move, dissolve, deletes) Implement the cells that reshape the tree rather than rewrite a span, in a new engine/structural.ts the engine delegates to: - replace @ parent (move): relocate a section's subtree beneath a new parent, re-levelling every heading in it by one uniform delta so its internal nesting is preserved; supports first/last/before/after placement, rejects cycles (moving beneath self/descendant) and unresolvable parents. - delete @ marker (dissolve): remove a heading line. When a same-level sibling already precedes the target the orphaned body/children are absorbed with no re-levelling; when it is first at its level the children are promoted to the parent and re-levelled up one. - delete @ content / @ markerAndContent: empty the body, or remove the whole subtree plus its trailing gap. - block deletes: empty a block's text, detach its ^id, or remove the block plus the blank line that separated a following block. Shared text helpers (line-ending normalization, relative-level fragment prep, the splice-and-package convenience) move into a new text.ts so the write and structural handlers share them without a circular import. applyEdits now breaks start-offset ties so a zero-length insertion sorts before a same-start deletion, which lets a move express insert-here and delete-old-span as two boundary-sharing edits without a spurious overlap error. Co-Authored-By: Claude Opus 4.8 --- src/engine.ts | 62 ++-------- src/engine/structural.ts | 233 +++++++++++++++++++++++++++++++++++ src/splice.ts | 10 +- src/tests/structural.test.ts | 195 +++++++++++++++++++++++++++++ src/text.ts | 71 +++++++++++ 5 files changed, 516 insertions(+), 55 deletions(-) create mode 100644 src/engine/structural.ts create mode 100644 src/tests/structural.test.ts create mode 100644 src/text.ts diff --git a/src/engine.ts b/src/engine.ts index c576717..33b8962 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -14,20 +14,20 @@ import { buildModel, DocumentModel, SectionNode, BlockNode } from "./model.js"; import { resolveTarget } from "./resolve.js"; -import { rebaseHeadings } from "./levels.js"; -import { applyEdits, Edit } from "./splice.js"; +import { Edit } from "./splice.js"; import { headingMarkerRange, subtreeContentRange, subtreeEnd, blockFullRange, } from "./ranges.js"; +import { toLineEnding, sectionFragment, splice } from "./text.js"; +import { structuralHeading, deleteBlock } from "./engine/structural.js"; import { Instruction, HeadingInstruction, BlockInstruction, PatchResult, - Warning, EngineError, PreconditionFailedError, TargetNotFoundError, @@ -41,43 +41,6 @@ const cellOf = (instruction: Instruction) => ({ scope: instruction.scope, }); -const normalizeToLf = (text: string): string => - text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - -/** Re-express `text` using the document's line ending. */ -const toLineEnding = (text: string, ending: "\n" | "\r\n"): string => - ending === "\n" - ? normalizeToLf(text) - : normalizeToLf(text).replace(/\n/g, "\r\n"); - -/** - * Normalize an inserted body/section fragment to end with exactly one line - * ending (or empty for an empty value), matching the model's invariant that a - * non-empty content span ends with a single terminator and the library owns the - * blank-line gap that follows. - */ -const endWithSingleEol = (text: string, ending: "\n" | "\r\n"): string => { - const stripped = text.replace(/(?:\r\n|\r|\n)+$/, ""); - return stripped.length === 0 ? "" : stripped + ending; -}; - -/** - * Turn a relative heading-bearing fragment into the exact bytes to splice in: - * rebase its `#`-levels by `baseline`, re-apply the document's line ending, and - * terminate it with a single ending. - */ -const sectionFragment = ( - value: string, - baseline: number, - model: DocumentModel -): { text: string; warnings: Warning[] } => { - const rebased = rebaseHeadings(value, baseline); - return { - text: endWithSingleEol(toLineEnding(rebased.text, model.lineEnding), model.lineEnding), - warnings: rebased.warnings, - }; -}; - /** The parent's source heading level, or 0 when the parent is the root. */ const parentLevel = (section: SectionNode): number => section.parent?.heading?.level ?? 0; @@ -90,17 +53,14 @@ const patchHeading = ( instruction: HeadingInstruction, section: SectionNode ): PatchResult => { - if (instruction.operation === "delete") { - throw new EngineError("heading delete is not yet implemented in this build"); - } - if (instruction.scope === "parent") { - throw new EngineError("heading move is not yet implemented in this build"); + if (instruction.operation === "delete" || instruction.scope === "parent") { + return structuralHeading(document, model, instruction, section); } // Excluding delete and parent narrows to HeadingWriteInstruction. const { operation, scope, content: value } = instruction; if (scope === "content") { - const fragment = sectionFragment(value, section.heading?.level ?? 0, model); + const fragment = sectionFragment(value, section.heading?.level ?? 0, model.lineEnding); const edit = contentEdit(section.content, operation, fragment.text); return splice(document, [edit], fragment.warnings); } @@ -110,7 +70,7 @@ const patchHeading = ( } // markerAndContent: the whole subtree, rebased to the parent's level. - const fragment = sectionFragment(value, parentLevel(section), model); + const fragment = sectionFragment(value, parentLevel(section), model.lineEnding); if (operation === "replace") { return splice( document, @@ -184,7 +144,7 @@ const patchBlock = ( block: BlockNode ): PatchResult => { if (instruction.operation === "delete") { - throw new EngineError("block delete is not yet implemented in this build"); + return deleteBlock(document, model, instruction, block); } // Excluding delete narrows to BlockWrite | BlockMarkerReplace; both carry a // string `content`. Block content and ids are literal, never rebased. @@ -226,12 +186,6 @@ const patchBlock = ( // --- Entry point --------------------------------------------------------- -const splice = ( - document: string, - edits: Edit[], - warnings: Warning[] -): PatchResult => ({ document: applyEdits(document, edits), warnings }); - /** * Apply a single {@link Instruction} to `document`, returning the new document * and any warnings. Throws {@link PreconditionFailedError} on an `ifMatch` diff --git a/src/engine/structural.ts b/src/engine/structural.ts new file mode 100644 index 0000000..d13387d --- /dev/null +++ b/src/engine/structural.ts @@ -0,0 +1,233 @@ +/** + * The structural cells of the algebra — the ones that reshape the tree rather + * than just rewrite a span: + * + * - `replace @ parent` (move): relocate a section's subtree beneath a new + * parent, re-levelling every heading in it by the same delta so its internal + * nesting is preserved. + * - `delete @ marker` (dissolve): remove a heading line. If a same-level + * sibling already precedes the target, its orphaned body/children are simply + * absorbed by the preceding text; if it is the first at its level, its + * children are promoted to its parent and re-levelled up one. + * - `delete @ content` / `delete @ markerAndContent`: empty the body, or remove + * the whole subtree plus its trailing gap. + * - block deletes: empty a block's text, detach its `^id`, or remove the whole + * block plus the blank line that separated it. + */ + +import { DocumentModel, SectionNode, BlockNode } from "../model.js"; +import { resolveHeading } from "../resolve.js"; +import { Edit } from "../splice.js"; +import { + headingMarkerRange, + subtreeContentRange, + subtreeEnd, + blockFullRange, +} from "../ranges.js"; +import { relevelText, endWithSingleEol, splice } from "../text.js"; +import { + HeadingInstruction, + HeadingMoveInstruction, + BlockDeleteInstruction, + Place, + PatchResult, + EngineError, + TargetNotFoundError, +} from "../instructions.js"; + +/** The subtree's first byte: a section's own marker, or its body for the root. */ +const subtreeStart = (section: SectionNode): number => + section.marker ? section.marker.start : section.content.start; + +// --- Move ---------------------------------------------------------------- + +/** Reject moving a section beneath itself or one of its own descendants. */ +const assertNoCycle = (section: SectionNode, newParent: SectionNode): void => { + let node: SectionNode | null = newParent; + while (node) { + if (node === section) { + throw new EngineError( + "cannot move a section beneath itself or one of its descendants" + ); + } + node = node.parent; + } +}; + +/** The byte offset at which a new child should be inserted under `newParent`. */ +const childInsertOffset = ( + model: DocumentModel, + newParent: SectionNode, + place: Place +): number => { + const children = newParent.children; + if (place === "first") { + return children.length ? subtreeStart(children[0]) : newParent.content.end; + } + if (place === "last") { + return children.length + ? subtreeEnd(children[children.length - 1]) + : newParent.content.end; + } + const addr = "before" in place ? place.before : place.after; + const sibling = resolveHeading(model, addr)?.section; + if (!sibling || sibling.parent !== newParent) { + throw new TargetNotFoundError( + `place anchor ${JSON.stringify(addr)} is not a child of the new parent` + ); + } + return "before" in place ? subtreeStart(sibling) : subtreeEnd(sibling); +}; + +const moveSection = ( + document: string, + model: DocumentModel, + instruction: HeadingMoveInstruction, + section: SectionNode +): PatchResult => { + if (!section.heading) { + throw new EngineError("the document root cannot be moved"); + } + const resolvedParent = resolveHeading(model, instruction.value.parent); + if (!resolvedParent) { + throw new TargetNotFoundError( + `could not resolve new parent ${JSON.stringify(instruction.value.parent)}` + ); + } + const newParent = resolvedParent.section; + assertNoCycle(section, newParent); + + // Re-level the whole subtree so the moved section sits at (new parent + 1), + // shifting every descendant by the same delta to preserve internal nesting. + const newParentLevel = newParent.heading?.level ?? 0; + const delta = newParentLevel + 1 - section.heading.level; + const source = subtreeContentRange(section); + const releveled = relevelText( + document.slice(source.start, source.end), + delta, + model.lineEnding + ); + const movedText = endWithSingleEol(releveled.text, model.lineEnding); + + const removal: Edit = { + range: { start: subtreeStart(section), end: subtreeEnd(section) }, + text: "", + }; + const at = childInsertOffset(model, newParent, instruction.value.place); + const insertion: Edit = { range: { start: at, end: at }, text: movedText }; + + return splice(document, [removal, insertion], releveled.warnings); +}; + +// --- Dissolve ------------------------------------------------------------ + +const dissolveHeading = ( + document: string, + model: DocumentModel, + section: SectionNode +): PatchResult => { + const markerRange = headingMarkerRange(section); // throws for the root + const parent = section.parent!; + const index = parent.children.indexOf(section); + const hasPrecedingSameLevel = parent.children + .slice(0, index) + .some((sibling) => sibling.heading!.level === section.heading!.level); + + const edits: Edit[] = [{ range: markerRange, text: "" }]; + let warnings: PatchResult["warnings"] = []; + + // First at its level: its children have nothing to nest under once the + // heading is gone, so promote them to the parent by re-levelling up one. + if (!hasPrecedingSameLevel && section.children.length > 0) { + const start = subtreeStart(section.children[0]); + const end = subtreeContentRange(section).end; + const releveled = relevelText(document.slice(start, end), -1, model.lineEnding); + edits.push({ range: { start, end }, text: releveled.text }); + warnings = releveled.warnings; + } + + return splice(document, edits, warnings); +}; + +// --- Heading delete dispatch --------------------------------------------- + +/** Handle the structural heading cells (`delete @ *`, `replace @ parent`). */ +export const structuralHeading = ( + document: string, + model: DocumentModel, + instruction: HeadingInstruction, + section: SectionNode +): PatchResult => { + if (instruction.scope === "parent") { + return moveSection(document, model, instruction, section); + } + // Only delete instructions reach here (writes are handled in engine.ts). + if (instruction.operation !== "delete") { + throw new EngineError( + `structuralHeading received a non-structural instruction (${instruction.operation} @ ${instruction.scope})` + ); + } + switch (instruction.scope) { + case "content": + return splice(document, [{ range: section.content, text: "" }], []); + case "markerAndContent": + return splice( + document, + [ + { + range: { start: subtreeStart(section), end: subtreeEnd(section) }, + text: "", + }, + ], + [] + ); + case "marker": + return dissolveHeading(document, model, section); + } +}; + +// --- Block delete -------------------------------------------------------- + +/** + * Consume the block's line terminator and, if one follows, a single blank-line + * separator, so removing a block does not leave a dangling gap. + */ +const consumeTrailingBlank = (document: string, from: number): number => { + let i = from; + const eatEol = (): boolean => { + if (document[i] === "\r" && document[i + 1] === "\n") { + i += 2; + return true; + } + if (document[i] === "\n" || document[i] === "\r") { + i += 1; + return true; + } + return false; + }; + eatEol(); // the terminator ending the block's line + eatEol(); // one following blank line, if present + return i; +}; + +/** Handle the block delete cells (`delete @ content|marker|markerAndContent`). */ +export const deleteBlock = ( + document: string, + _model: DocumentModel, + instruction: BlockDeleteInstruction, + block: BlockNode +): PatchResult => { + switch (instruction.scope) { + case "content": + return splice(document, [{ range: block.content, text: "" }], []); + case "marker": + // Detach the id, keeping the content (the marker span includes any + // leading whitespace before `^`). + return splice(document, [{ range: block.marker, text: "" }], []); + case "markerAndContent": { + const full = blockFullRange(block); + const end = consumeTrailingBlank(document, full.end); + return splice(document, [{ range: { start: full.start, end }, text: "" }], []); + } + } +}; diff --git a/src/splice.ts b/src/splice.ts index dfce6eb..774966f 100644 --- a/src/splice.ts +++ b/src/splice.ts @@ -21,7 +21,15 @@ export class OverlappingEditsError extends Error {} * document. */ export const applyEdits = (document: string, edits: Edit[]): string => { - const sorted = [...edits].sort((a, b) => a.range.start - b.range.start); + // Sort by start, breaking ties so a zero-length insertion precedes a + // same-start replacement/deletion (its text lands just before the region). + // This lets a move express "insert here" and "delete the old span" as two + // edits that share a boundary without spuriously overlapping. + const sorted = [...edits].sort( + (a, b) => + a.range.start - b.range.start || + (a.range.end - a.range.start) - (b.range.end - b.range.start) + ); const parts: string[] = []; let cursor = 0; for (const { range, text } of sorted) { diff --git a/src/tests/structural.test.ts b/src/tests/structural.test.ts new file mode 100644 index 0000000..cfa74af --- /dev/null +++ b/src/tests/structural.test.ts @@ -0,0 +1,195 @@ +import { patch } from "../engine"; +import { EngineError, TargetNotFoundError } from "../instructions"; + +const DOC = "# A\na-body\n\n## B\nb-body\n\n# C\nc-body\n"; + +describe("patch — heading delete cells", () => { + test("delete @ content empties the body, keeping the heading and gap", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "delete", + scope: "content", + }); + expect(result.document).toBe("# A\n\n## B\nb-body\n\n# C\nc-body\n"); + }); + + test("delete @ markerAndContent removes the subtree and its trailing gap", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "delete", + scope: "markerAndContent", + }); + expect(result.document).toBe("# A\na-body\n\n# C\nc-body\n"); + }); +}); + +describe("patch — dissolve (delete @ marker)", () => { + test("with no preceding same-level sibling, children are promoted and re-levelled up one", () => { + const doc = "# A\n## S\n### child\nx\n"; + const result = patch(doc, { + targetType: "heading", + target: ["A", "S"], + operation: "delete", + scope: "marker", + }); + expect(result.document).toBe("# A\n## child\nx\n"); + }); + + test("with a preceding same-level sibling, the heading is simply removed and children absorbed", () => { + const doc = "# A\n## P\n## S\n### child\nx\n"; + const result = patch(doc, { + targetType: "heading", + target: ["A", "S"], + operation: "delete", + scope: "marker", + }); + expect(result.document).toBe("# A\n## P\n### child\nx\n"); + }); + + test("dissolving a childless heading is a pure one-line deletion", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "delete", + scope: "marker", + }); + expect(result.document).toBe("# A\na-body\n\nb-body\n\n# C\nc-body\n"); + }); + + test("dissolving the document root is rejected", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: null, + operation: "delete", + scope: "marker", + }) + ).toThrow(); + }); +}); + +describe("patch — move (replace @ parent)", () => { + test("moves a subsection to the document root, re-levelling it down to h1", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "parent", + value: { parent: null, place: "last" }, + }); + expect(result.document).toBe( + "# A\na-body\n\n# C\nc-body\n# B\nb-body\n" + ); + }); + + test("moves a top-level section beneath a sibling, re-levelling it up", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["C"], + operation: "replace", + scope: "parent", + value: { parent: ["A"], place: "last" }, + }); + // C (h1) becomes a child of A (h1) -> h2, appended after B. + expect(result.document).toBe( + "# A\na-body\n\n## B\nb-body\n\n## C\nc-body\n" + ); + }); + + test("place `before` a named sibling inserts the moved subtree ahead of it", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["C"], + operation: "replace", + scope: "parent", + value: { parent: ["A"], place: { before: ["A", "B"] } }, + }); + expect(result.document).toBe( + "# A\na-body\n\n## C\nc-body\n## B\nb-body\n\n" + ); + }); + + test("re-levelling preserves the internal nesting of the moved subtree", () => { + const doc = "# A\n## B\n### deep\nd\n\n# C\nc\n"; + const result = patch(doc, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "parent", + value: { parent: ["C"], place: "last" }, + }); + // B (h2) -> child of C (h1) -> h2, deep (h3) shifts with it -> h3. + expect(result.document).toContain("# C\nc\n## B\n### deep\nd\n"); + }); + + test("moving a section beneath itself is rejected", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "parent", + value: { parent: ["A", "B"], place: "last" }, + }) + ).toThrow(EngineError); + }); + + test("an unresolvable new parent raises TargetNotFoundError", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "parent", + value: { parent: ["Nope"], place: "last" }, + }) + ).toThrow(TargetNotFoundError); + }); +}); + +describe("patch — block delete cells", () => { + const BLOCK_DOC = "a paragraph ^ref\n"; + + test("delete @ content empties the block text, keeping the id", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "delete", + scope: "content", + }); + expect(result.document).toBe(" ^ref\n"); + }); + + test("delete @ marker detaches the id, keeping the content", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "delete", + scope: "marker", + }); + expect(result.document).toBe("a paragraph\n"); + }); + + test("delete @ markerAndContent removes the block and its trailing gap", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "delete", + scope: "markerAndContent", + }); + expect(result.document).toBe(""); + }); + + test("delete @ markerAndContent consumes the blank line separating a following block", () => { + const doc = "a ^x\n\nb ^y\n"; + const result = patch(doc, { + targetType: "block", + target: "x", + operation: "delete", + scope: "markerAndContent", + }); + expect(result.document).toBe("b ^y\n"); + }); +}); diff --git a/src/text.ts b/src/text.ts new file mode 100644 index 0000000..ae587e4 --- /dev/null +++ b/src/text.ts @@ -0,0 +1,71 @@ +/** + * Text helpers shared by the engine's write and structural handlers: line-ending + * normalization and relative→absolute heading fragment preparation. Kept in + * their own module so `engine.ts` and `engine/structural.ts` can share them + * without a circular import. + */ + +import { rebaseHeadings } from "./levels.js"; +import { applyEdits, Edit } from "./splice.js"; +import { PatchResult, Warning } from "./instructions.js"; + +export type LineEnding = "\n" | "\r\n"; + +/** Apply `edits` to `document` and package the result with its `warnings`. */ +export const splice = ( + document: string, + edits: Edit[], + warnings: Warning[] +): PatchResult => ({ document: applyEdits(document, edits), warnings }); + +const normalizeToLf = (text: string): string => + text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + +/** Re-express `text` using the document's line ending. */ +export const toLineEnding = (text: string, ending: LineEnding): string => + ending === "\n" + ? normalizeToLf(text) + : normalizeToLf(text).replace(/\n/g, "\r\n"); + +/** + * Normalize an inserted body/section fragment to end with exactly one line + * ending (or empty for an empty value), matching the model's invariant that a + * non-empty content span ends with a single terminator and the library owns the + * blank-line gap that follows. + */ +export const endWithSingleEol = (text: string, ending: LineEnding): string => { + const stripped = text.replace(/(?:\r\n|\r|\n)+$/, ""); + return stripped.length === 0 ? "" : stripped + ending; +}; + +/** + * Turn a relative heading-bearing fragment into the exact bytes to splice in: + * rebase its `#`-levels by `baseline`, re-apply the document's line ending, and + * terminate it with a single ending. + */ +export const sectionFragment = ( + value: string, + baseline: number, + ending: LineEnding +): { text: string; warnings: Warning[] } => { + const rebased = rebaseHeadings(value, baseline); + return { + text: endWithSingleEol(toLineEnding(rebased.text, ending), ending), + warnings: rebased.warnings, + }; +}; + +/** + * Re-level an already-placed heading fragment by `delta`, preserving its own + * trailing structure (no single-eol normalization). Used by move and dissolve + * to shift a contiguous subtree's `#`-levels in place. Returns `null` edits + * when `delta` is 0. + */ +export const relevelText = ( + slice: string, + delta: number, + ending: LineEnding +): { text: string; warnings: Warning[] } => { + const rebased = rebaseHeadings(slice, delta); + return { text: toLineEnding(rebased.text, ending), warnings: rebased.warnings }; +}; From c972ce14c52e7d9480006d50da85f24a07c82c3c Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 11:49:31 -0500 Subject: [PATCH 12/73] Add the 2.0 engine's frontmatter cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement frontmatter patching in a new engine/frontmatter.ts the engine delegates to. Frontmatter is edited by parsing the block into an ordered list of key/value pairs, applying the operation to that list, and re-serializing the whole block with yaml.stringify — sidestepping the fiddliness of serializing a single value in place, at the cost of reformatting the block. The model's entry ranges still resolve and order the entries, and the body below the block is byte-preserved. Cells covered: - content replace: set the value; content prepend/append: merge (list concat, dict merge, string concat) reusing the 1.x rule, with a MergeError on incompatible types; content delete: clear the value to null, keeping the key. - marker replace: rename the key, keeping its value and position. - markerAndContent replace: re-emit the entry with a new value; delete: remove the whole entry (dropping the block entirely when it was the last); prepend/ append: insert new entries (given as a dictionary) before/after the anchor. Frontmatter values are JSON, never relative markdown, so nothing here is rebased. Co-Authored-By: Claude Opus 4.8 --- src/engine.ts | 5 +- src/engine/frontmatter.ts | 124 ++++++++++++++++++++++++++ src/tests/frontmatter.test.ts | 158 ++++++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 src/engine/frontmatter.ts create mode 100644 src/tests/frontmatter.test.ts diff --git a/src/engine.ts b/src/engine.ts index 33b8962..9cbcd4c 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -23,6 +23,7 @@ import { } from "./ranges.js"; import { toLineEnding, sectionFragment, splice } from "./text.js"; import { structuralHeading, deleteBlock } from "./engine/structural.js"; +import { patchFrontmatter } from "./engine/frontmatter.js"; import { Instruction, HeadingInstruction, @@ -228,9 +229,7 @@ export const patch = ( return patchBlock(document, model, instruction, resolved.block); } if (instruction.targetType === "frontmatter" && resolved.kind === "frontmatter") { - throw new EngineError( - "frontmatter patching is not yet implemented in this build" - ); + return patchFrontmatter(document, model, instruction); } throw new EngineError( `resolved ${resolved.kind} does not match ${instruction.targetType} target` diff --git a/src/engine/frontmatter.ts b/src/engine/frontmatter.ts new file mode 100644 index 0000000..229498f --- /dev/null +++ b/src/engine/frontmatter.ts @@ -0,0 +1,124 @@ +/** + * Frontmatter cells. Unlike heading and block edits, which splice byte ranges, + * frontmatter is edited by parsing the block into an ordered list of key/value + * pairs, applying the operation to that list, and re-serializing the whole + * block with `yaml.stringify`. This sidesteps the fiddliness of serializing a + * single value in place (block scalars, flow vs. block style, quoting) at the + * cost of reformatting the block; the model's entry ranges are still used to + * resolve and order entries. Frontmatter values are JSON, never relative + * markdown, so nothing here is rebased. + */ + +import * as yaml from "yaml"; + +import { DocumentModel } from "../model.js"; +import { spliceRange } from "../splice.js"; +import { + isAppendableFrontmatterType, + isDictionary, + isList, + isString, +} from "../typeGuards.js"; +import { + FrontmatterInstruction, + PatchResult, + MergeError, + TargetNotFoundError, +} from "../instructions.js"; + +type Pair = [string, unknown]; + +/** List concat / dict merge / string concat, mirroring the 1.x merge rule. */ +const mergeValues = (a: unknown, b: unknown): unknown => { + if (isList(a) && isList(b)) { + return [...a, ...b]; + } + if (isDictionary(a) && isDictionary(b)) { + return { ...a, ...b }; + } + if (isString(a) && isString(b)) { + return a + b; + } + throw new MergeError( + "frontmatter values are not mergeable (need two lists, two dicts, or two strings)" + ); +}; + +/** Re-serialize the ordered pairs as a `---` block in the document's ending. */ +const serializeBlock = (pairs: Pair[], lineEnding: "\n" | "\r\n"): string => { + if (pairs.length === 0) { + return ""; + } + const object: Record = {}; + for (const [key, value] of pairs) { + object[key] = value; + } + const raw = `---\n${yaml.stringify(object).trimEnd()}\n---\n`; + return lineEnding === "\n" ? raw : raw.replaceAll("\n", lineEnding); +}; + +/** Apply a frontmatter instruction by regenerating the block from its pairs. */ +export const patchFrontmatter = ( + document: string, + model: DocumentModel, + instruction: FrontmatterInstruction +): PatchResult => { + const pairs: Pair[] = model.frontmatter.entries.map((entry) => [ + entry.key, + entry.value, + ]); + const key = instruction.target; + const index = pairs.findIndex(([existing]) => existing === key); + if (index === -1) { + throw new TargetNotFoundError(`frontmatter key "${key}" was not found`); + } + + if (instruction.scope === "marker") { + // Rename the key, keeping its value and position (replace-only per matrix). + pairs[index] = [instruction.content, pairs[index][1]]; + } else if (instruction.operation === "delete") { + if (instruction.scope === "content") { + pairs[index] = [key, null]; // clear the value, keep the key + } else { + pairs.splice(index, 1); // remove the whole entry + } + } else { + // Value instruction: replace / prepend / append at content or markerAndContent. + const content = instruction.content; + if (instruction.scope === "content") { + if (instruction.operation === "replace") { + pairs[index] = [key, content]; + } else { + const current = pairs[index][1]; + if (!isAppendableFrontmatterType(content) || !isAppendableFrontmatterType(current)) { + throw new MergeError( + `frontmatter key "${key}" cannot be merged with the given value` + ); + } + pairs[index] = [ + key, + instruction.operation === "append" + ? mergeValues(current, content) + : mergeValues(content, current), + ]; + } + } else if (instruction.operation === "replace") { + pairs[index] = [key, content]; // re-emit the whole entry with a new value + } else { + // Insert new entries (a dictionary) before/after the anchor entry. + if (!isDictionary(content)) { + throw new MergeError( + "inserting frontmatter entries requires a dictionary of key/value pairs" + ); + } + const at = instruction.operation === "prepend" ? index : index + 1; + pairs.splice(at, 0, ...(Object.entries(content) as Pair[])); + } + } + + const block = model.frontmatter.block ?? { start: 0, end: 0 }; + return { + document: spliceRange(document, block, serializeBlock(pairs, model.lineEnding)), + warnings: [], + }; +}; diff --git a/src/tests/frontmatter.test.ts b/src/tests/frontmatter.test.ts new file mode 100644 index 0000000..8b7e1f3 --- /dev/null +++ b/src/tests/frontmatter.test.ts @@ -0,0 +1,158 @@ +import { patch } from "../engine"; +import { MergeError } from "../instructions"; + +const FM = "---\ntitle: Hello\ntags:\n - a\n - b\n---\nbody text\n"; + +describe("patch — frontmatter content cells", () => { + test("replace @ content sets the value, preserving the body", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "replace", + scope: "content", + content: "Goodbye", + }); + expect(result.document).toBe( + "---\ntitle: Goodbye\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); + + test("append @ content concatenates onto a list value", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "tags", + operation: "append", + scope: "content", + content: ["c"], + }); + expect(result.document).toBe( + "---\ntitle: Hello\ntags:\n - a\n - b\n - c\n---\nbody text\n" + ); + }); + + test("prepend @ content concatenates onto the front of a list value", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "tags", + operation: "prepend", + scope: "content", + content: ["z"], + }); + expect(result.document).toBe( + "---\ntitle: Hello\ntags:\n - z\n - a\n - b\n---\nbody text\n" + ); + }); + + test("append @ content concatenates strings", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "append", + scope: "content", + content: " World", + }); + expect(result.document).toBe( + "---\ntitle: Hello World\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); + + test("delete @ content clears the value but keeps the key", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "delete", + scope: "content", + }); + expect(result.document).toBe( + "---\ntitle: null\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); + + test("mismatched value types are not mergeable", () => { + expect(() => + patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "append", + scope: "content", + content: 5, + }) + ).toThrow(MergeError); + }); +}); + +describe("patch — frontmatter marker cell", () => { + test("replace @ marker renames the key, keeping its value and position", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "replace", + scope: "marker", + content: "heading", + }); + expect(result.document).toBe( + "---\nheading: Hello\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); +}); + +describe("patch — frontmatter markerAndContent cells", () => { + test("delete @ markerAndContent removes the whole entry", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "tags", + operation: "delete", + scope: "markerAndContent", + }); + expect(result.document).toBe("---\ntitle: Hello\n---\nbody text\n"); + }); + + test("deleting the last entry removes the frontmatter block entirely", () => { + const result = patch("---\nonly: x\n---\nbody\n", { + targetType: "frontmatter", + target: "only", + operation: "delete", + scope: "markerAndContent", + }); + expect(result.document).toBe("body\n"); + }); + + test("replace @ markerAndContent re-emits the entry with a new value", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "replace", + scope: "markerAndContent", + content: 42, + }); + expect(result.document).toBe( + "---\ntitle: 42\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); + + test("append @ markerAndContent inserts a new entry after the anchor", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "append", + scope: "markerAndContent", + content: { author: "me" }, + }); + expect(result.document).toBe( + "---\ntitle: Hello\nauthor: me\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); + + test("prepend @ markerAndContent inserts a new entry before the anchor", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "tags", + operation: "prepend", + scope: "markerAndContent", + content: { author: "me" }, + }); + expect(result.document).toBe( + "---\ntitle: Hello\nauthor: me\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); +}); From 2ccfe994ab4af6df2899d199661882d53dd35a03 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 11:54:28 -0500 Subject: [PATCH 13/73] Add createTargetIfMissing support across heading, block, and frontmatter Value-writing operations may now create their target when it does not exist (rename, move, and delete still require a live target): - Headings (new engine/create.ts): create the missing trailing segments of the address as a nested chain under the deepest existing ancestor, with levels running from that ancestor's actual level + 1 so a skipped level in the address never leaves a hole (the 1.x skipped-depth bug), then place the content in the deepest new section. A created level past h6 still writes but surfaces a heading-depth-overflow warning. - Blocks: mint `content ^id` as a new paragraph at the end of the document, separated from existing content by a blank line. - Frontmatter: create the key (seeding an empty value of the content's kind so a merge has something to merge onto), creating the whole `---` block when the document has none. Co-Authored-By: Claude Opus 4.8 --- src/engine.ts | 13 +++- src/engine/create.ts | 121 +++++++++++++++++++++++++++++++ src/engine/frontmatter.ts | 21 +++++- src/tests/create.test.ts | 146 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 src/engine/create.ts create mode 100644 src/tests/create.test.ts diff --git a/src/engine.ts b/src/engine.ts index 9cbcd4c..3432f75 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -24,6 +24,7 @@ import { import { toLineEnding, sectionFragment, splice } from "./text.js"; import { structuralHeading, deleteBlock } from "./engine/structural.js"; import { patchFrontmatter } from "./engine/frontmatter.js"; +import { createHeading, createBlock } from "./engine/create.js"; import { Instruction, HeadingInstruction, @@ -209,9 +210,15 @@ export const patch = ( const resolved = resolveTarget(model, instruction); if (!resolved) { if (instruction.createTargetIfMissing) { - throw new EngineError( - "createTargetIfMissing is not yet implemented in this build" - ); + switch (instruction.targetType) { + case "heading": + return createHeading(document, model, instruction); + case "block": + return createBlock(document, model, instruction); + case "frontmatter": + // patchFrontmatter creates the key itself when it is missing. + return patchFrontmatter(document, model, instruction); + } } throw new TargetNotFoundError( `could not resolve ${instruction.targetType} target ${JSON.stringify( diff --git a/src/engine/create.ts b/src/engine/create.ts new file mode 100644 index 0000000..36a74d8 --- /dev/null +++ b/src/engine/create.ts @@ -0,0 +1,121 @@ +/** + * Target creation for `createTargetIfMissing`. Only value-writing operations + * create — you cannot rename, move, or delete something that does not exist. + * + * - Headings: create the missing trailing segments of the address as a nested + * chain under the deepest existing ancestor, with levels running from that + * ancestor's *actual* level + 1 (so a skipped level in the address never + * leaves a hole — the 1.x skipped-depth bug), then place the content in the + * deepest new section. A created level past h6 still writes but warns. + * - Blocks: mint `content ^id` as a new paragraph at the end of the document. + */ + +import { DocumentModel } from "../model.js"; +import { resolveHeading } from "../resolve.js"; +import { subtreeEnd } from "../ranges.js"; +import { sectionFragment, toLineEnding, splice } from "../text.js"; +import { + HeadingInstruction, + BlockInstruction, + PatchResult, + Warning, + EngineError, + TargetNotFoundError, +} from "../instructions.js"; + +const MAX_HEADING_LEVEL = 6; + +/** Create a missing heading (and any missing ancestors) and place content in it. */ +export const createHeading = ( + document: string, + model: DocumentModel, + instruction: HeadingInstruction +): PatchResult => { + if (instruction.operation === "delete" || instruction.scope === "parent") { + throw new TargetNotFoundError( + "cannot create a heading for a delete or move instruction" + ); + } + if (instruction.scope !== "content") { + throw new EngineError( + "createTargetIfMissing for headings supports content-scope writes only" + ); + } + const collapsed = (instruction.target ?? []).filter( + (segment): segment is string => segment !== null + ); + if (collapsed.length === 0) { + throw new EngineError("the document root cannot be created"); + } + + // Find the deepest existing ancestor prefix; the remaining segments are new. + let ancestor = model.root; + let matched = 0; + for (let length = collapsed.length - 1; length >= 1; length--) { + const resolved = resolveHeading(model, collapsed.slice(0, length)); + if (resolved) { + ancestor = resolved.section; + matched = length; + break; + } + } + const toCreate = collapsed.slice(matched); + + const warnings: Warning[] = []; + const parts: string[] = []; + let level = ancestor.heading?.level ?? 0; + for (const segment of toCreate) { + level += 1; + if (level > MAX_HEADING_LEVEL) { + warnings.push({ + code: "heading-depth-overflow", + message: `Created heading "${segment}" resolves to level ${level}, beyond Markdown's maximum of ${MAX_HEADING_LEVEL}; it will not be structurally addressable.`, + }); + } + parts.push("#".repeat(level) + " " + segment + model.lineEnding); + } + + // The content lands in the deepest new section, at its level baseline. + const body = sectionFragment(instruction.content, level, model.lineEnding); + if (body.text) { + parts.push(body.text); + } + warnings.push(...body.warnings); + + const at = subtreeEnd(ancestor); + return splice( + document, + [{ range: { start: at, end: at }, text: parts.join("") }], + warnings + ); +}; + +/** Mint a new `content ^id` block at the end of the document. */ +export const createBlock = ( + document: string, + model: DocumentModel, + instruction: BlockInstruction +): PatchResult => { + if (instruction.operation === "delete" || instruction.scope !== "content") { + throw new EngineError( + "createTargetIfMissing for blocks supports content-scope writes only" + ); + } + const value = toLineEnding(instruction.content, model.lineEnding); + const blockText = `${value} ^${instruction.target}`; + const le = model.lineEnding; + const separator = + document.length === 0 + ? "" + : document.endsWith(le + le) + ? "" + : document.endsWith(le) + ? le + : le + le; + const at = document.length; + return splice( + document, + [{ range: { start: at, end: at }, text: separator + blockText + le }], + [] + ); +}; diff --git a/src/engine/frontmatter.ts b/src/engine/frontmatter.ts index 229498f..a88206a 100644 --- a/src/engine/frontmatter.ts +++ b/src/engine/frontmatter.ts @@ -68,9 +68,26 @@ export const patchFrontmatter = ( entry.value, ]); const key = instruction.target; - const index = pairs.findIndex(([existing]) => existing === key); + let index = pairs.findIndex(([existing]) => existing === key); if (index === -1) { - throw new TargetNotFoundError(`frontmatter key "${key}" was not found`); + // Creation is only meaningful when setting/merging a value: a content write + // or a whole-entry replace. Renaming, deleting, or inserting relative to a + // key that does not exist has no sensible meaning. + const creatable = + instruction.createTargetIfMissing && + ((instruction.scope === "content" && instruction.operation !== "delete") || + (instruction.scope === "markerAndContent" && + instruction.operation === "replace")); + if (!creatable) { + throw new TargetNotFoundError(`frontmatter key "${key}" was not found`); + } + // Seed an empty value of the content's kind so a merge has something to + // merge onto; a replace overwrites it regardless. `creatable` guarantees a + // value instruction here, so `content` is present. + const content = "content" in instruction ? instruction.content : ""; + const seed = isList(content) ? [] : isDictionary(content) ? {} : ""; + pairs.push([key, seed]); + index = pairs.length - 1; } if (instruction.scope === "marker") { diff --git a/src/tests/create.test.ts b/src/tests/create.test.ts new file mode 100644 index 0000000..c40236d --- /dev/null +++ b/src/tests/create.test.ts @@ -0,0 +1,146 @@ +import { patch } from "../engine"; +import { TargetNotFoundError } from "../instructions"; + +describe("createTargetIfMissing — headings", () => { + test("creates a missing child under an existing ancestor and places content", () => { + const result = patch("# A\na-body\n", { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "content", + content: "hi", + createTargetIfMissing: true, + }); + expect(result.document).toBe("# A\na-body\n## B\nhi\n"); + }); + + test("creates a whole missing chain with consecutive levels (no skipped-depth hole)", () => { + const result = patch("# A\na\n", { + targetType: "heading", + target: ["A", "B", "C"], + operation: "replace", + scope: "content", + content: "x", + createTargetIfMissing: true, + }); + expect(result.document).toBe("# A\na\n## B\n### C\nx\n"); + }); + + test("creates a top-level heading when no ancestor matches", () => { + const result = patch("# A\na\n", { + targetType: "heading", + target: ["New"], + operation: "replace", + scope: "content", + content: "body", + createTargetIfMissing: true, + }); + expect(result.document).toBe("# A\na\n# New\nbody\n"); + }); + + test("warns when a created heading would exceed h6", () => { + const result = patch("###### A\na\n", { + targetType: "heading", + target: ["A", "B"], // A is h6, B would be h7 + operation: "replace", + scope: "content", + content: "x", + createTargetIfMissing: true, + }); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("heading-depth-overflow"); + expect(result.document).toContain("####### B\n"); + }); + + test("without createTargetIfMissing a missing heading still throws", () => { + expect(() => + patch("# A\na\n", { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "content", + content: "x", + }) + ).toThrow(TargetNotFoundError); + }); +}); + +describe("createTargetIfMissing — blocks", () => { + test("mints a new block at the end of the document", () => { + const result = patch("a paragraph\n", { + targetType: "block", + target: "ref", + operation: "replace", + scope: "content", + content: "new block", + createTargetIfMissing: true, + }); + expect(result.document).toBe("a paragraph\n\nnew block ^ref\n"); + }); + + test("mints a block in an empty document without a leading separator", () => { + const result = patch("", { + targetType: "block", + target: "ref", + operation: "replace", + scope: "content", + content: "only block", + createTargetIfMissing: true, + }); + expect(result.document).toBe("only block ^ref\n"); + }); +}); + +describe("createTargetIfMissing — frontmatter", () => { + test("creates a new key when the frontmatter block already exists", () => { + const result = patch("---\ntitle: Hello\n---\nbody\n", { + targetType: "frontmatter", + target: "author", + operation: "replace", + scope: "content", + content: "me", + createTargetIfMissing: true, + }); + expect(result.document).toBe( + "---\ntitle: Hello\nauthor: me\n---\nbody\n" + ); + }); + + test("creates the frontmatter block when the document has none", () => { + const result = patch("body only\n", { + targetType: "frontmatter", + target: "title", + operation: "replace", + scope: "content", + content: "New", + createTargetIfMissing: true, + }); + expect(result.document).toBe("---\ntitle: New\n---\nbody only\n"); + }); + + test("creating with a merge seeds an empty value of the right kind", () => { + const result = patch("---\ntitle: Hello\n---\nbody\n", { + targetType: "frontmatter", + target: "tags", + operation: "append", + scope: "content", + content: ["a"], + createTargetIfMissing: true, + }); + expect(result.document).toBe( + "---\ntitle: Hello\ntags:\n - a\n---\nbody\n" + ); + }); + + test("a missing key without createTargetIfMissing throws", () => { + expect(() => + patch("---\ntitle: Hello\n---\nbody\n", { + targetType: "frontmatter", + target: "author", + operation: "replace", + scope: "content", + content: "me", + }) + ).toThrow(TargetNotFoundError); + }); +}); From cfc2c22f666805e3ccde3749c3024b7d21d9624a Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 11:56:24 -0500 Subject: [PATCH 14/73] Generalize rejectIfContentPreexists and export the 2.0 engine Implement the rejectIfContentPreexists idempotency guard for the new engine: a prepend/append with string content is refused when that content already appears in the target's current span. As in 1.x this applies to heading and block writes only (not frontmatter merges) and never blocks a replace, which overwrites regardless. Wire the 2.0 surface into the package root: export `patch`, the full Instruction discriminated-union types, PatchResult/Warning, the cell-validity guards, and the engine error classes, alongside the still-live 1.x applyPatch/getDocumentMap. Co-Authored-By: Claude Opus 4.8 --- src/engine.ts | 53 +++++++++++++++++++++++++++++++ src/index.ts | 39 +++++++++++++++++++++++ src/tests/safety.test.ts | 68 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 src/tests/safety.test.ts diff --git a/src/engine.ts b/src/engine.ts index 3432f75..b6333f9 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -33,8 +33,10 @@ import { EngineError, PreconditionFailedError, TargetNotFoundError, + ContentPreexistsError, assertValidCell, } from "./instructions.js"; +import { ResolvedTarget } from "./resolve.js"; /** The subset of an instruction {@link assertValidCell} inspects. */ const cellOf = (instruction: Instruction) => ({ @@ -47,6 +49,39 @@ const cellOf = (instruction: Instruction) => ({ const parentLevel = (section: SectionNode): number => section.parent?.heading?.level ?? 0; +/** + * The current text of the span a write targets, for the `rejectIfContentPreexists` + * idempotency guard. Returns `null` when the notion does not apply (frontmatter, + * or a heading marker on the markerless root). + */ +const scopeSpanText = ( + document: string, + resolved: ResolvedTarget, + scope: string +): string | null => { + if (resolved.kind === "heading") { + const section = resolved.section; + const range = + scope === "content" + ? section.content + : scope === "marker" + ? section.marker + : subtreeContentRange(section); + return range ? document.slice(range.start, range.end) : null; + } + if (resolved.kind === "block") { + const block = resolved.block; + const range = + scope === "content" + ? block.content + : scope === "marker" + ? block.marker + : blockFullRange(block); + return document.slice(range.start, range.end); + } + return null; +}; + // --- Heading handlers ---------------------------------------------------- const patchHeading = ( @@ -227,6 +262,24 @@ export const patch = ( ); } + // rejectIfContentPreexists keeps prepend/append idempotent: refuse to insert + // string content that already appears in the target's current span. As in + // 1.x this applies to heading and block writes only, not frontmatter. + if ( + instruction.rejectIfContentPreexists && + (instruction.operation === "prepend" || instruction.operation === "append") && + "content" in instruction && + typeof instruction.content === "string" && + instruction.content.trim().length > 0 + ) { + const span = scopeSpanText(document, resolved, instruction.scope); + if (span !== null && span.includes(instruction.content.trim())) { + throw new ContentPreexistsError( + `the target already contains the content to ${instruction.operation}` + ); + } + } + // `resolveTarget` dispatches on `targetType`, so the resolved kind always // matches the instruction; narrow explicitly for the type system. if (instruction.targetType === "heading" && resolved.kind === "heading") { diff --git a/src/index.ts b/src/index.ts index 9b4e071..a59769d 100755 --- a/src/index.ts +++ b/src/index.ts @@ -15,3 +15,42 @@ export { } from "./map.js"; export * from "./types.js"; + +// --- 2.0 engine ---------------------------------------------------------- + +export { patch } from "./engine.js"; +export { + EngineError, + InvalidCellError, + TargetNotFoundError, + PreconditionFailedError, + ContentPreexistsError, + MergeError, + isValidCell, + assertValidCell, +} from "./instructions.js"; +export type { + Instruction, + HeadingInstruction, + HeadingWriteInstruction, + HeadingMoveInstruction, + HeadingDeleteInstruction, + BlockInstruction, + BlockWriteInstruction, + BlockMarkerReplaceInstruction, + BlockDeleteInstruction, + FrontmatterInstruction, + FrontmatterValueInstruction, + FrontmatterRenameInstruction, + FrontmatterDeleteInstruction, + Operation, + Scope, + TargetType, + HeadingAddress, + ParentSpec, + Place, + PatchResult, + Warning, + WarningCode, + Cell, +} from "./instructions.js"; diff --git a/src/tests/safety.test.ts b/src/tests/safety.test.ts new file mode 100644 index 0000000..aa3eb58 --- /dev/null +++ b/src/tests/safety.test.ts @@ -0,0 +1,68 @@ +import { patch, ContentPreexistsError, Instruction } from "../index"; + +describe("rejectIfContentPreexists", () => { + const DOC = "# A\nalready here\n"; + + test("append is refused when the content already appears in the target span", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "already here", + rejectIfContentPreexists: true, + }) + ).toThrow(ContentPreexistsError); + }); + + test("append proceeds when the content is not already present", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "new line", + rejectIfContentPreexists: true, + }); + expect(result.document).toBe("# A\nalready here\nnew line\n"); + }); + + test("replace is never blocked by the guard (it overwrites)", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "content", + content: "already here", + rejectIfContentPreexists: true, + }); + expect(result.document).toBe("# A\nalready here\n"); + }); + + test("the guard does not fire without the flag", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "already here", + }); + expect(result.document).toBe("# A\nalready here\nalready here\n"); + }); +}); + +describe("public 2.0 exports", () => { + test("patch and the Instruction type are re-exported from the package root", () => { + const instruction: Instruction = { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "content", + content: "x", + }; + const result = patch("# A\nold\n", instruction); + expect(result.document).toBe("# A\nx\n"); + expect(result.warnings).toEqual([]); + }); +}); From 0c9a1131974fb2efd8a08e1c847e7ab6db1fb6f3 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 12:03:28 -0500 Subject: [PATCH 15/73] Add map/patch symmetry and splice-locality property tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the engine's top design constraint: a successful, non-overflow write leaves its target addressable in the map derived from the result — verified across content replace, whole-subtree rename, heading/block/frontmatter creation, move, and sibling insert, each also asserting the result is a lossless model partition (serializeModel round-trips). Add a locality property over the conformance fixtures: a content-scope replace byte-preserves everything outside the edited body (prefix and suffix are unchanged), restricted to headings with a unique address so the resolver targets the measured occurrence. Co-Authored-By: Claude Opus 4.8 --- src/tests/symmetry.test.ts | 183 +++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/tests/symmetry.test.ts diff --git a/src/tests/symmetry.test.ts b/src/tests/symmetry.test.ts new file mode 100644 index 0000000..114203b --- /dev/null +++ b/src/tests/symmetry.test.ts @@ -0,0 +1,183 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { patch } from "../engine"; +import { Instruction } from "../instructions"; +import { buildModel, eachSection, serializeModel } from "../model"; +import { projectMap, headingPath } from "../projection"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const CONFORMANCE_DIR = path.join(__dirname, "conformance"); + +const arrayEquals = (a: unknown[], b: unknown[]): boolean => + a.length === b.length && a.every((value, index) => value === b[index]); + +const collapse = (path: (string | null)[]): (string | null)[] => + path.filter((segment) => segment !== null); + +const collapsedHeadings = (document: string): (string | null)[][] => + projectMap(buildModel(document)).headings.map(collapse); + +const hasHeading = (document: string, wanted: string[]): boolean => + collapsedHeadings(document).some((heading) => arrayEquals(heading, wanted)); + +// The top design constraint: a successful, non-overflow write leaves its target +// addressable in the map derived from the result. +describe("map/patch symmetry — written targets are addressable in the next map", () => { + const DOC = "# A\na-body\n\n## B\nb-body\n\n# C\nc-body\n"; + const FM = "---\ntitle: Hello\n---\nbody\n"; + + const cases: Array<{ + name: string; + document: string; + instruction: Instruction; + check: (result: string) => boolean; + }> = [ + { + name: "content replace keeps the heading addressable", + document: DOC, + instruction: { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "content", + content: "z", + }, + check: (r) => hasHeading(r, ["A", "B"]), + }, + { + name: "markerAndContent replace makes the renamed heading addressable", + document: DOC, + instruction: { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "markerAndContent", + content: "# Renamed\nx", + }, + check: (r) => hasHeading(r, ["A", "Renamed"]), + }, + { + name: "created heading is addressable", + document: DOC, + instruction: { + targetType: "heading", + target: ["A", "New"], + operation: "replace", + scope: "content", + content: "body", + createTargetIfMissing: true, + }, + check: (r) => hasHeading(r, ["A", "New"]), + }, + { + name: "moved heading is addressable under its new parent", + document: DOC, + instruction: { + targetType: "heading", + target: ["C"], + operation: "replace", + scope: "parent", + value: { parent: ["A"], place: "last" }, + }, + check: (r) => hasHeading(r, ["A", "C"]), + }, + { + name: "sibling insert makes the new section addressable", + document: DOC, + instruction: { + targetType: "heading", + target: ["A", "B"], + operation: "prepend", + scope: "markerAndContent", + content: "# Sib\ns", + }, + check: (r) => hasHeading(r, ["A", "Sib"]), + }, + { + name: "created block is addressable", + document: DOC, + instruction: { + targetType: "block", + target: "blk", + operation: "replace", + scope: "content", + content: "a block", + createTargetIfMissing: true, + }, + check: (r) => projectMap(buildModel(r)).blocks.includes("blk"), + }, + { + name: "created frontmatter key is addressable", + document: FM, + instruction: { + targetType: "frontmatter", + target: "author", + operation: "replace", + scope: "content", + content: "me", + createTargetIfMissing: true, + }, + check: (r) => + projectMap(buildModel(r)).frontmatterFields.includes("author"), + }, + ]; + + for (const testCase of cases) { + test(testCase.name, () => { + const result = patch(testCase.document, testCase.instruction); + expect(result.warnings).toEqual([]); + expect(testCase.check(result.document)).toBe(true); + // The result must itself be a well-formed model (lossless partition). + expect(serializeModel(result.document, buildModel(result.document))).toBe( + result.document + ); + }); + } +}); + +// A content-scope replace must not disturb any byte outside the edited body. +describe("splice locality — content edits are confined to the target body", () => { + const fixtures = fs + .readdirSync(CONFORMANCE_DIR) + .filter((file) => file.endsWith(".md")) + .map((file) => fs.readFileSync(path.join(CONFORMANCE_DIR, file), "utf-8")); + + test("prefix and suffix around a section's body are byte-preserved", () => { + for (const document of fixtures) { + const model = buildModel(document); + + // Only target headings whose padded address is unique, so the resolver + // cannot pick a different occurrence than the one we measured. + const paths = new Map(); + eachSection(model.root, (section) => { + if (section.heading) { + const key = JSON.stringify(headingPath(section)); + paths.set(key, (paths.get(key) ?? 0) + 1); + } + }); + + eachSection(model.root, (section) => { + if (!section.heading) { + return; + } + if (paths.get(JSON.stringify(headingPath(section))) !== 1) { + return; + } + const before = document.slice(0, section.content.start); + const after = document.slice(section.content.end); + const result = patch(document, { + targetType: "heading", + target: headingPath(section), + operation: "replace", + scope: "content", + content: "LOCALMARK", + }).document; + expect(result.startsWith(before)).toBe(true); + expect(result.endsWith(after)).toBe(true); + }); + } + }); +}); From 6af2029c27af6524531b9653b336974ca0ed466d Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 15:22:42 -0500 Subject: [PATCH 16/73] Make heading content scope address the whole subtree below the heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2.0 engine mapped a heading's `content` scope to `section.content` — the section's *direct* body only (span A), stopping at the first subsection. This diverged from 1.x, where `content` addressed the whole subtree minus the heading line (span B): a single GET/replace round-tripped a section's full body, subsections included. Callers relied on that to fetch or rewrite a section in one request without resorting to `markerAndContent`. Restore span B as the `content` semantics. A new `headingContentRange` helper returns `[section.content.start, lastDescendant.trailingGap.start]` — the body through the last descendant, excluding the heading line and the final gap. For a leaf section it coincides with the direct body, so only sections with children change behavior. The write handler, the `rejectIfContentPreexists` span, and `delete @ content` all now use it, so replace/prepend/append/delete at `content` act on the subtree consistently. Tests updated to the span-B semantics: replace/append/delete at `content` on a parent now absorb its subsections, and the splice-locality property measures the span-B range. No-op-identity and round-trip cases target leaf sections, where raw content round-trips without relative-level rebasing. Co-Authored-By: Claude Opus 4.8 --- src/engine.ts | 5 +++-- src/engine/structural.ts | 8 ++++--- src/ranges.ts | 22 +++++++++++++++---- src/tests/engine.test.ts | 42 +++++++++++++++++++++++++----------- src/tests/structural.test.ts | 16 ++++++++++++-- src/tests/symmetry.test.ts | 8 +++++-- 6 files changed, 76 insertions(+), 25 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index b6333f9..d4544ba 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -17,6 +17,7 @@ import { resolveTarget } from "./resolve.js"; import { Edit } from "./splice.js"; import { headingMarkerRange, + headingContentRange, subtreeContentRange, subtreeEnd, blockFullRange, @@ -63,7 +64,7 @@ const scopeSpanText = ( const section = resolved.section; const range = scope === "content" - ? section.content + ? headingContentRange(section) : scope === "marker" ? section.marker : subtreeContentRange(section); @@ -98,7 +99,7 @@ const patchHeading = ( if (scope === "content") { const fragment = sectionFragment(value, section.heading?.level ?? 0, model.lineEnding); - const edit = contentEdit(section.content, operation, fragment.text); + const edit = contentEdit(headingContentRange(section), operation, fragment.text); return splice(document, [edit], fragment.warnings); } diff --git a/src/engine/structural.ts b/src/engine/structural.ts index d13387d..ae8b0c8 100644 --- a/src/engine/structural.ts +++ b/src/engine/structural.ts @@ -9,8 +9,9 @@ * sibling already precedes the target, its orphaned body/children are simply * absorbed by the preceding text; if it is the first at its level, its * children are promoted to its parent and re-levelled up one. - * - `delete @ content` / `delete @ markerAndContent`: empty the body, or remove - * the whole subtree plus its trailing gap. + * - `delete @ content` / `delete @ markerAndContent`: empty the section's body + * (its subtree below the heading line), or remove the whole subtree — heading + * line included — plus its trailing gap. * - block deletes: empty a block's text, detach its `^id`, or remove the whole * block plus the blank line that separated it. */ @@ -20,6 +21,7 @@ import { resolveHeading } from "../resolve.js"; import { Edit } from "../splice.js"; import { headingMarkerRange, + headingContentRange, subtreeContentRange, subtreeEnd, blockFullRange, @@ -169,7 +171,7 @@ export const structuralHeading = ( } switch (instruction.scope) { case "content": - return splice(document, [{ range: section.content, text: "" }], []); + return splice(document, [{ range: headingContentRange(section), text: "" }], []); case "markerAndContent": return splice( document, diff --git a/src/ranges.ts b/src/ranges.ts index ce9b32a..5aa41d9 100644 --- a/src/ranges.ts +++ b/src/ranges.ts @@ -1,9 +1,10 @@ /** * Range geometry over model nodes: turn a resolved node into the byte spans an - * operation acts on. The key distinction is that `content` acts on a section's - * *direct* body while `markerAndContent` (and moves and deletes) act on the - * whole *subtree*, which is contiguous in document order because a section's - * descendants immediately follow its content and gap. + * operation acts on. Both `content` and `markerAndContent` act on a section's + * whole *subtree* — which is contiguous in document order because a section's + * descendants immediately follow its content and gap — the difference being that + * `content` excludes the heading line itself (the subtree *minus* its own + * marker) while `markerAndContent` includes it. */ import { DocumentRange } from "./types.js"; @@ -28,6 +29,19 @@ export const subtreeContentRange = (section: SectionNode): DocumentRange => ({ end: lastDescendant(section).trailingGap.start, }); +/** + * The `content` scope of a heading: everything below the heading line through + * the last descendant's content, excluding the heading line itself and the final + * trailing gap. This is the whole subtree *minus* its own marker — the span 1.x + * `content` addressed — so a single content read/replace round-trips a section's + * full body, subsections included. For a leaf section it coincides with the + * direct body (`section.content`). + */ +export const headingContentRange = (section: SectionNode): DocumentRange => ({ + start: section.content.start, + end: lastDescendant(section).trailingGap.start, +}); + /** The subtree's end including its trailing separator gap; used for clean deletes. */ export const subtreeEnd = (section: SectionNode): number => lastDescendant(section).trailingGap.end; diff --git a/src/tests/engine.test.ts b/src/tests/engine.test.ts index 910eca2..689da46 100644 --- a/src/tests/engine.test.ts +++ b/src/tests/engine.test.ts @@ -11,20 +11,35 @@ import { RootHasNoMarkerError } from "../ranges"; const DOC = "# A\na-body\n\n## B\nb-body\n\n# C\nc-body\n"; describe("patch — heading content cells", () => { - test("replace @ content sets the section body, leaving the gap and siblings", () => { + test("replace @ content on a leaf sets just that section's body", () => { + // B has no children, so its `content` span is exactly its direct body. const result = patch(DOC, { targetType: "heading", - target: ["A"], + target: ["A", "B"], operation: "replace", scope: "content", - content: "new-a", + content: "new-b", }); expect(result.document).toBe( - "# A\nnew-a\n\n## B\nb-body\n\n# C\nc-body\n" + "# A\na-body\n\n## B\nnew-b\n\n# C\nc-body\n" ); expect(result.warnings).toEqual([]); }); + test("replace @ content spans the whole subtree below the heading (subsections included)", () => { + // A's `content` is everything under it minus its own heading line — a-body + // *and* the ## B subsection — so replacing it absorbs the child section. + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "content", + content: "new-a", + }); + expect(result.document).toBe("# A\nnew-a\n\n# C\nc-body\n"); + expect(result.warnings).toEqual([]); + }); + test("prepend @ content inserts at the top of the body", () => { const result = patch(DOC, { targetType: "heading", @@ -38,7 +53,9 @@ describe("patch — heading content cells", () => { ); }); - test("append @ content inserts at the bottom of the body, before the gap", () => { + test("append @ content inserts at the bottom of the subtree body, before the gap", () => { + // A's content spans through ## B, so an append lands after B's body, not + // between a-body and the subsection. const result = patch(DOC, { targetType: "heading", target: ["A"], @@ -47,7 +64,7 @@ describe("patch — heading content cells", () => { content: "bot", }); expect(result.document).toBe( - "# A\na-body\nbot\n\n## B\nb-body\n\n# C\nc-body\n" + "# A\na-body\n\n## B\nb-body\nbot\n\n# C\nc-body\n" ); }); @@ -253,12 +270,12 @@ describe("patch — preconditions and resolution", () => { // Compute the version via a no-op resolve by patching with the right token. const first = patch(DOC, { targetType: "heading", - target: ["A"], + target: ["A", "B"], operation: "append", scope: "content", content: "x", }); - expect(first.document).toContain("a-body\nx\n"); + expect(first.document).toContain("b-body\nx\n"); }); test("ifMatch not matching the current version fails without modifying the document", () => { @@ -288,14 +305,15 @@ describe("patch — preconditions and resolution", () => { }); describe("patch — no-op identity", () => { - test("replacing a section body with its own current text is byte-identity", () => { - // A's body is "a-body\n"; replacing with the same text must not change bytes. + test("replacing a leaf section body with its own current text is byte-identity", () => { + // B is a leaf, so its content span is just "b-body\n"; replacing with the + // same text must not change any bytes. const result = patch(DOC, { targetType: "heading", - target: ["A"], + target: ["A", "B"], operation: "replace", scope: "content", - content: "a-body", + content: "b-body", }); expect(result.document).toBe(DOC); }); diff --git a/src/tests/structural.test.ts b/src/tests/structural.test.ts index cfa74af..b95ff4f 100644 --- a/src/tests/structural.test.ts +++ b/src/tests/structural.test.ts @@ -4,14 +4,26 @@ import { EngineError, TargetNotFoundError } from "../instructions"; const DOC = "# A\na-body\n\n## B\nb-body\n\n# C\nc-body\n"; describe("patch — heading delete cells", () => { - test("delete @ content empties the body, keeping the heading and gap", () => { + test("delete @ content empties the subtree body, keeping the heading and gap", () => { + // A's content spans through its ## B subsection, so emptying it removes the + // subsection too, leaving only the bare heading and the following gap. const result = patch(DOC, { targetType: "heading", target: ["A"], operation: "delete", scope: "content", }); - expect(result.document).toBe("# A\n\n## B\nb-body\n\n# C\nc-body\n"); + expect(result.document).toBe("# A\n\n# C\nc-body\n"); + }); + + test("delete @ content on a leaf empties just that section's body", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "delete", + scope: "content", + }); + expect(result.document).toBe("# A\na-body\n\n## B\n\n# C\nc-body\n"); }); test("delete @ markerAndContent removes the subtree and its trailing gap", () => { diff --git a/src/tests/symmetry.test.ts b/src/tests/symmetry.test.ts index 114203b..b403cf4 100644 --- a/src/tests/symmetry.test.ts +++ b/src/tests/symmetry.test.ts @@ -6,6 +6,7 @@ import { patch } from "../engine"; import { Instruction } from "../instructions"; import { buildModel, eachSection, serializeModel } from "../model"; import { projectMap, headingPath } from "../projection"; +import { headingContentRange } from "../ranges"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -166,8 +167,11 @@ describe("splice locality — content edits are confined to the target body", () if (paths.get(JSON.stringify(headingPath(section))) !== 1) { return; } - const before = document.slice(0, section.content.start); - const after = document.slice(section.content.end); + // `content` scope spans the subtree below the heading (span B), so the + // preserved suffix begins where that span ends, not at the direct body. + const body = headingContentRange(section); + const before = document.slice(0, body.start); + const after = document.slice(body.end); const result = patch(document, { targetType: "heading", target: headingPath(section), From 57b5e8bf0b2a7e58e12b936309a228f7447d74bc Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 15:24:04 -0500 Subject: [PATCH 17/73] Rename the move carrier from `value` to `destination` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `replace @ parent` (move) carried its `ParentSpec` in a field named `value`. That name is about to be needed for structured JSON write content (frontmatter values, and later table-row cells), which would collide with the move's placement spec. Rename the move carrier to `destination`, which also reads more clearly at the call site: a move says where the section goes. No behavior change — purely the field name on `HeadingMoveInstruction` and its readers in the structural engine, plus the move test cases. Co-Authored-By: Claude Opus 4.8 --- src/engine/structural.ts | 6 +++--- src/instructions.ts | 6 ++++-- src/tests/instructions.test.ts | 2 +- src/tests/structural.test.ts | 12 ++++++------ src/tests/symmetry.test.ts | 2 +- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/engine/structural.ts b/src/engine/structural.ts index ae8b0c8..865cea7 100644 --- a/src/engine/structural.ts +++ b/src/engine/structural.ts @@ -90,10 +90,10 @@ const moveSection = ( if (!section.heading) { throw new EngineError("the document root cannot be moved"); } - const resolvedParent = resolveHeading(model, instruction.value.parent); + const resolvedParent = resolveHeading(model, instruction.destination.parent); if (!resolvedParent) { throw new TargetNotFoundError( - `could not resolve new parent ${JSON.stringify(instruction.value.parent)}` + `could not resolve new parent ${JSON.stringify(instruction.destination.parent)}` ); } const newParent = resolvedParent.section; @@ -115,7 +115,7 @@ const moveSection = ( range: { start: subtreeStart(section), end: subtreeEnd(section) }, text: "", }; - const at = childInsertOffset(model, newParent, instruction.value.place); + const at = childInsertOffset(model, newParent, instruction.destination.place); const insertion: Edit = { range: { start: at, end: at }, text: movedText }; return splice(document, [removal, insertion], releveled.warnings); diff --git a/src/instructions.ts b/src/instructions.ts index ea65700..4464fe9 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -32,7 +32,7 @@ export type Place = | { before: HeadingAddress } | { after: HeadingAddress }; -/** The value of a `replace @ parent` (move) instruction. */ +/** The `destination` of a `replace @ parent` (move) instruction. */ export interface ParentSpec { /** The section's new parent, or `null`/`[]` to move to the document root. */ parent: HeadingAddress; @@ -84,7 +84,9 @@ export interface HeadingWriteInstruction extends HeadingTargeted { export interface HeadingMoveInstruction extends HeadingTargeted { operation: "replace"; scope: "parent"; - value: ParentSpec; + /** The section's new placement in the tree. Carried in its own field so it is + * never confused with the string `content`/JSON `value` write carriers. */ + destination: ParentSpec; } /** * `delete` a heading's body (`content`), its subtree (`markerAndContent`), or diff --git a/src/tests/instructions.test.ts b/src/tests/instructions.test.ts index 38fca6e..870c67a 100644 --- a/src/tests/instructions.test.ts +++ b/src/tests/instructions.test.ts @@ -121,7 +121,7 @@ describe("Instruction typing (compile-time)", () => { operation: "replace", scope: "parent", target: ["Overview", "Details"], - value: { parent: ["Appendix"], place: "last" }, + destination: { parent: ["Appendix"], place: "last" }, }, { targetType: "heading", diff --git a/src/tests/structural.test.ts b/src/tests/structural.test.ts index b95ff4f..3f28fd1 100644 --- a/src/tests/structural.test.ts +++ b/src/tests/structural.test.ts @@ -89,7 +89,7 @@ describe("patch — move (replace @ parent)", () => { target: ["A", "B"], operation: "replace", scope: "parent", - value: { parent: null, place: "last" }, + destination: { parent: null, place: "last" }, }); expect(result.document).toBe( "# A\na-body\n\n# C\nc-body\n# B\nb-body\n" @@ -102,7 +102,7 @@ describe("patch — move (replace @ parent)", () => { target: ["C"], operation: "replace", scope: "parent", - value: { parent: ["A"], place: "last" }, + destination: { parent: ["A"], place: "last" }, }); // C (h1) becomes a child of A (h1) -> h2, appended after B. expect(result.document).toBe( @@ -116,7 +116,7 @@ describe("patch — move (replace @ parent)", () => { target: ["C"], operation: "replace", scope: "parent", - value: { parent: ["A"], place: { before: ["A", "B"] } }, + destination: { parent: ["A"], place: { before: ["A", "B"] } }, }); expect(result.document).toBe( "# A\na-body\n\n## C\nc-body\n## B\nb-body\n\n" @@ -130,7 +130,7 @@ describe("patch — move (replace @ parent)", () => { target: ["A", "B"], operation: "replace", scope: "parent", - value: { parent: ["C"], place: "last" }, + destination: { parent: ["C"], place: "last" }, }); // B (h2) -> child of C (h1) -> h2, deep (h3) shifts with it -> h3. expect(result.document).toContain("# C\nc\n## B\n### deep\nd\n"); @@ -143,7 +143,7 @@ describe("patch — move (replace @ parent)", () => { target: ["A"], operation: "replace", scope: "parent", - value: { parent: ["A", "B"], place: "last" }, + destination: { parent: ["A", "B"], place: "last" }, }) ).toThrow(EngineError); }); @@ -155,7 +155,7 @@ describe("patch — move (replace @ parent)", () => { target: ["A", "B"], operation: "replace", scope: "parent", - value: { parent: ["Nope"], place: "last" }, + destination: { parent: ["Nope"], place: "last" }, }) ).toThrow(TargetNotFoundError); }); diff --git a/src/tests/symmetry.test.ts b/src/tests/symmetry.test.ts index b403cf4..af1b186 100644 --- a/src/tests/symmetry.test.ts +++ b/src/tests/symmetry.test.ts @@ -81,7 +81,7 @@ describe("map/patch symmetry — written targets are addressable in the next map target: ["C"], operation: "replace", scope: "parent", - value: { parent: ["A"], place: "last" }, + destination: { parent: ["A"], place: "last" }, }, check: (r) => hasHeading(r, ["A", "C"]), }, From 8e19b4749de3ac3f41f8bc568a5dbe783e1a8ed8 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 15:27:01 -0500 Subject: [PATCH 18/73] Carry frontmatter JSON values in `value`, keeping `content` a string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontmatter value writes rode in the same `content` field as heading and block text, but a frontmatter value is arbitrary JSON while every other `content` payload is a literal string (and heading content additionally carries relative `#`-levels). Overloading one field with two payload shapes would force a type-discriminator at the eventual REST/MCP wire boundary and make a value's handling depend on runtime type sniffing. Split the carriers: an instruction now uses exactly one of three fields chosen by what the payload is — `content: string` (heading/block text, and a frontmatter key rename, which is a literal label), `value: unknown` (frontmatter JSON values), or `destination: ParentSpec` (a move). `FrontmatterValueInstruction` moves its payload from `content: unknown` to `value: unknown`; the engine and tests read it there. Key renames stay on `content` since a key name is a plain string like a block id. Co-Authored-By: Claude Opus 4.8 --- src/engine/frontmatter.ts | 8 ++++---- src/instructions.ts | 26 +++++++++++++++++--------- src/tests/create.test.ts | 8 ++++---- src/tests/frontmatter.test.ts | 16 ++++++++-------- src/tests/instructions.test.ts | 2 +- src/tests/symmetry.test.ts | 2 +- 6 files changed, 35 insertions(+), 27 deletions(-) diff --git a/src/engine/frontmatter.ts b/src/engine/frontmatter.ts index a88206a..38bb48b 100644 --- a/src/engine/frontmatter.ts +++ b/src/engine/frontmatter.ts @@ -81,10 +81,10 @@ export const patchFrontmatter = ( if (!creatable) { throw new TargetNotFoundError(`frontmatter key "${key}" was not found`); } - // Seed an empty value of the content's kind so a merge has something to + // Seed an empty value of the payload's kind so a merge has something to // merge onto; a replace overwrites it regardless. `creatable` guarantees a - // value instruction here, so `content` is present. - const content = "content" in instruction ? instruction.content : ""; + // value instruction here, so `value` is present. + const content = "value" in instruction ? instruction.value : ""; const seed = isList(content) ? [] : isDictionary(content) ? {} : ""; pairs.push([key, seed]); index = pairs.length - 1; @@ -101,7 +101,7 @@ export const patchFrontmatter = ( } } else { // Value instruction: replace / prepend / append at content or markerAndContent. - const content = instruction.content; + const content = instruction.value; if (instruction.scope === "content") { if (instruction.operation === "replace") { pairs[index] = [key, content]; diff --git a/src/instructions.ts b/src/instructions.ts index 4464fe9..5edb065 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -2,11 +2,17 @@ * Instruction shapes for the 2.0 patch engine. * * The engine is one algebra: an {@link Operation} applied to a {@link Scope} of - * a target node. The scope names the *value* being edited (`content` = the - * body, `marker` = the label, `markerAndContent` = the whole node/subtree, - * `parent` = the node's place in the tree); the operation says what happens to - * that value. Every scope value is a plain string except `parent`, whose value - * is a structured {@link ParentSpec}. + * a target node. The scope names what is edited (`content` = the body, `marker` + * = the label, `markerAndContent` = the whole node/subtree, `parent` = the + * node's place in the tree); the operation says what happens to it. + * + * An instruction carries its payload in exactly one of three mutually-exclusive + * fields, chosen by what the payload *is*, not by scope: + * - `content: string` — literal text: heading body/label, block text/id, and a + * frontmatter key rename. Heading-bearing content carries `#`-levels relative + * to the edited span's container (see `levels.ts`). + * - `value: unknown` — arbitrary structured JSON: frontmatter values. + * - `destination: ParentSpec` — where a moved section lands. * * These are plain TypeScript discriminated unions. A published Zod schema * (from which obsidian-local-rest-api will derive its MCP and OpenAPI docs) is @@ -133,14 +139,16 @@ export type BlockInstruction = // --- Frontmatter instructions -------------------------------------------- /** - * `replace`/`prepend`/`append` a frontmatter value (`content`) or whole entry - * (`markerAndContent`). `prepend`/`append` merge (list concat, dict merge, - * string concat). Values are JSON, never relative markdown. + * `replace`/`prepend`/`append` a frontmatter value (`content` scope) or whole + * entry (`markerAndContent`). `prepend`/`append` merge (list concat, dict + * merge, string concat). The payload is arbitrary JSON, never markdown, so it + * rides in the structured `value` carrier rather than the string `content` one + * used by heading and block writes. */ export interface FrontmatterValueInstruction extends FrontmatterTargeted { operation: "replace" | "prepend" | "append"; scope: "content" | "markerAndContent"; - content: unknown; + value: unknown; } /** `replace @ marker`: rename the frontmatter key. */ export interface FrontmatterRenameInstruction extends FrontmatterTargeted { diff --git a/src/tests/create.test.ts b/src/tests/create.test.ts index c40236d..6780410 100644 --- a/src/tests/create.test.ts +++ b/src/tests/create.test.ts @@ -98,7 +98,7 @@ describe("createTargetIfMissing — frontmatter", () => { target: "author", operation: "replace", scope: "content", - content: "me", + value: "me", createTargetIfMissing: true, }); expect(result.document).toBe( @@ -112,7 +112,7 @@ describe("createTargetIfMissing — frontmatter", () => { target: "title", operation: "replace", scope: "content", - content: "New", + value: "New", createTargetIfMissing: true, }); expect(result.document).toBe("---\ntitle: New\n---\nbody only\n"); @@ -124,7 +124,7 @@ describe("createTargetIfMissing — frontmatter", () => { target: "tags", operation: "append", scope: "content", - content: ["a"], + value: ["a"], createTargetIfMissing: true, }); expect(result.document).toBe( @@ -139,7 +139,7 @@ describe("createTargetIfMissing — frontmatter", () => { target: "author", operation: "replace", scope: "content", - content: "me", + value: "me", }) ).toThrow(TargetNotFoundError); }); diff --git a/src/tests/frontmatter.test.ts b/src/tests/frontmatter.test.ts index 8b7e1f3..d2298c4 100644 --- a/src/tests/frontmatter.test.ts +++ b/src/tests/frontmatter.test.ts @@ -10,7 +10,7 @@ describe("patch — frontmatter content cells", () => { target: "title", operation: "replace", scope: "content", - content: "Goodbye", + value: "Goodbye", }); expect(result.document).toBe( "---\ntitle: Goodbye\ntags:\n - a\n - b\n---\nbody text\n" @@ -23,7 +23,7 @@ describe("patch — frontmatter content cells", () => { target: "tags", operation: "append", scope: "content", - content: ["c"], + value: ["c"], }); expect(result.document).toBe( "---\ntitle: Hello\ntags:\n - a\n - b\n - c\n---\nbody text\n" @@ -36,7 +36,7 @@ describe("patch — frontmatter content cells", () => { target: "tags", operation: "prepend", scope: "content", - content: ["z"], + value: ["z"], }); expect(result.document).toBe( "---\ntitle: Hello\ntags:\n - z\n - a\n - b\n---\nbody text\n" @@ -49,7 +49,7 @@ describe("patch — frontmatter content cells", () => { target: "title", operation: "append", scope: "content", - content: " World", + value: " World", }); expect(result.document).toBe( "---\ntitle: Hello World\ntags:\n - a\n - b\n---\nbody text\n" @@ -75,7 +75,7 @@ describe("patch — frontmatter content cells", () => { target: "title", operation: "append", scope: "content", - content: 5, + value: 5, }) ).toThrow(MergeError); }); @@ -123,7 +123,7 @@ describe("patch — frontmatter markerAndContent cells", () => { target: "title", operation: "replace", scope: "markerAndContent", - content: 42, + value: 42, }); expect(result.document).toBe( "---\ntitle: 42\ntags:\n - a\n - b\n---\nbody text\n" @@ -136,7 +136,7 @@ describe("patch — frontmatter markerAndContent cells", () => { target: "title", operation: "append", scope: "markerAndContent", - content: { author: "me" }, + value: { author: "me" }, }); expect(result.document).toBe( "---\ntitle: Hello\nauthor: me\ntags:\n - a\n - b\n---\nbody text\n" @@ -149,7 +149,7 @@ describe("patch — frontmatter markerAndContent cells", () => { target: "tags", operation: "prepend", scope: "markerAndContent", - content: { author: "me" }, + value: { author: "me" }, }); expect(result.document).toBe( "---\ntitle: Hello\nauthor: me\ntags:\n - a\n - b\n---\nbody text\n" diff --git a/src/tests/instructions.test.ts b/src/tests/instructions.test.ts index 870c67a..cb63483 100644 --- a/src/tests/instructions.test.ts +++ b/src/tests/instructions.test.ts @@ -141,7 +141,7 @@ describe("Instruction typing (compile-time)", () => { operation: "append", scope: "content", target: "reviewers", - content: ["alice"], + value: ["alice"], }, { targetType: "frontmatter", diff --git a/src/tests/symmetry.test.ts b/src/tests/symmetry.test.ts index af1b186..0c2b8f9 100644 --- a/src/tests/symmetry.test.ts +++ b/src/tests/symmetry.test.ts @@ -118,7 +118,7 @@ describe("map/patch symmetry — written targets are addressable in the next map target: "author", operation: "replace", scope: "content", - content: "me", + value: "me", createTargetIfMissing: true, }, check: (r) => From dc77a1c65cf1d933c84becb1e8d195a807a6bdfa Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 15:30:05 -0500 Subject: [PATCH 19/73] Default an omitted scope to content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.x let callers omit `targetScope`, defaulting it to `content`; the 2.0 engine required `scope` on every instruction. Restore the ergonomic default so the common "edit this section/block/key's content" case needs no scope annotation. `patch()` now accepts an `InstructionInput` — a mapped view of `Instruction` that makes `scope` optional on exactly the members whose scope union admits `content` (the write and delete cells), while keeping it required on the marker-only and parent-only members, where it selects a behavior with no sensible default (a move must still name scope: "parent"). `withDefaultScope` fills an omitted scope with `content` before validation, so every internal handler continues to see an explicit, strict `Instruction`. Adds runtime tests that scope-less heading/block/frontmatter writes default to content while an explicit scope is honored, plus compile-time assertions that the input type is optional where content is valid and that a move is never defaulted. Co-Authored-By: Claude Opus 4.8 --- src/engine.ts | 13 ++++++--- src/index.ts | 1 + src/instructions.ts | 26 +++++++++++++++++ src/tests/engine.test.ts | 52 ++++++++++++++++++++++++++++++++++ src/tests/instructions.test.ts | 44 ++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index d4544ba..c1e8d4b 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -28,6 +28,7 @@ import { patchFrontmatter } from "./engine/frontmatter.js"; import { createHeading, createBlock } from "./engine/create.js"; import { Instruction, + InstructionInput, HeadingInstruction, BlockInstruction, PatchResult, @@ -36,6 +37,7 @@ import { TargetNotFoundError, ContentPreexistsError, assertValidCell, + withDefaultScope, } from "./instructions.js"; import { ResolvedTarget } from "./resolve.js"; @@ -225,15 +227,18 @@ const patchBlock = ( // --- Entry point --------------------------------------------------------- /** - * Apply a single {@link Instruction} to `document`, returning the new document - * and any warnings. Throws {@link PreconditionFailedError} on an `ifMatch` - * mismatch, {@link TargetNotFoundError} when the target does not resolve, and + * Apply a single instruction to `document`, returning the new document and any + * warnings. Accepts an {@link InstructionInput}: an omitted `scope` defaults to + * `content` before anything inspects the instruction. Throws + * {@link PreconditionFailedError} on an `ifMatch` mismatch, + * {@link TargetNotFoundError} when the target does not resolve, and * {@link InvalidCellError} for a combination outside the algebra. */ export const patch = ( document: string, - instruction: Instruction + input: InstructionInput ): PatchResult => { + const instruction = withDefaultScope(input); const model = buildModel(document); assertValidCell(cellOf(instruction)); diff --git a/src/index.ts b/src/index.ts index a59769d..e6bde83 100755 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ export { } from "./instructions.js"; export type { Instruction, + InstructionInput, HeadingInstruction, HeadingWriteInstruction, HeadingMoveInstruction, diff --git a/src/instructions.ts b/src/instructions.ts index 5edb065..716490d 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -172,6 +172,32 @@ export type Instruction = | BlockInstruction | FrontmatterInstruction; +/** + * Make `scope` optional on exactly those members whose scope union admits + * `content` — the write and delete cells — while leaving it required on the + * marker-only and parent-only members, where it selects a specific behavior and + * has no sensible default. Distributes over the {@link Instruction} union. + */ +type WithOptionalContentScope = T extends { scope: infer S } + ? "content" extends S + ? Omit & { scope?: S } + : T + : T; + +/** + * A public instruction as a caller may write it: `scope` may be omitted wherever + * it would default to `content` (mirroring 1.x's optional `targetScope`). + * {@link patch} normalizes this to a full {@link Instruction} before anything + * inspects it, so every internal handler still sees an explicit scope. + */ +export type InstructionInput = WithOptionalContentScope; + +/** Default an omitted `scope` to `content`, yielding a full {@link Instruction}. */ +export const withDefaultScope = (input: InstructionInput): Instruction => { + const scope = (input as { scope?: Scope }).scope ?? "content"; + return { ...input, scope } as Instruction; +}; + // --- Result and warnings ------------------------------------------------- export type WarningCode = "heading-depth-overflow"; diff --git a/src/tests/engine.test.ts b/src/tests/engine.test.ts index 689da46..db117a7 100644 --- a/src/tests/engine.test.ts +++ b/src/tests/engine.test.ts @@ -304,6 +304,58 @@ describe("patch — preconditions and resolution", () => { }); }); +describe("patch — scope defaults to content", () => { + test("a heading write with no scope edits the section body", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + content: "z", + }); + expect(result.document).toBe("# A\na-body\n\n## B\nz\n\n# C\nc-body\n"); + }); + + test("a heading delete with no scope empties the section body", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "delete", + }); + expect(result.document).toBe("# A\na-body\n\n## B\n\n# C\nc-body\n"); + }); + + test("a block write with no scope edits the block text", () => { + const result = patch("a paragraph ^ref\n", { + targetType: "block", + target: "ref", + operation: "replace", + content: "changed", + }); + expect(result.document).toBe("changed ^ref\n"); + }); + + test("a frontmatter write with no scope sets the value", () => { + const result = patch("---\ntitle: Hello\n---\nbody\n", { + targetType: "frontmatter", + target: "title", + operation: "replace", + value: "Bye", + }); + expect(result.document).toBe("---\ntitle: Bye\n---\nbody\n"); + }); + + test("an explicit scope is still honored over the default", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "marker", + content: "Renamed", + }); + expect(result.document).toContain("## Renamed\n"); + }); +}); + describe("patch — no-op identity", () => { test("replacing a leaf section body with its own current text is byte-identity", () => { // B is a leaf, so its content span is just "b-body\n"; replacing with the diff --git a/src/tests/instructions.test.ts b/src/tests/instructions.test.ts index cb63483..db5d886 100644 --- a/src/tests/instructions.test.ts +++ b/src/tests/instructions.test.ts @@ -3,9 +3,11 @@ import { Scope, TargetType, Instruction, + InstructionInput, isValidCell, assertValidCell, InvalidCellError, + withDefaultScope, } from "../instructions"; const OPERATIONS: Operation[] = ["replace", "prepend", "append", "delete"]; @@ -154,3 +156,45 @@ describe("Instruction typing (compile-time)", () => { expect(examples).toHaveLength(6); }); }); + +describe("InstructionInput — scope defaulting boundary", () => { + test("scope may be omitted where it would default to content", () => { + // A write/delete cell: `scope` is optional on the input. + const headingWrite: InstructionInput = { + targetType: "heading", + target: ["A"], + operation: "replace", + content: "x", + }; + const frontmatterValue: InstructionInput = { + targetType: "frontmatter", + target: "title", + operation: "replace", + value: "x", + }; + expect(withDefaultScope(headingWrite).scope).toBe("content"); + expect(withDefaultScope(frontmatterValue).scope).toBe("content"); + }); + + test("a move is never defaulted — it must name scope: parent", () => { + // @ts-expect-error a `destination` without scope: "parent" matches no member. + const move: InstructionInput = { + targetType: "heading", + target: ["A"], + operation: "replace", + destination: { parent: null, place: "last" }, + }; + void move; + }); + + test("an explicit scope is preserved by the default", () => { + const explicit: InstructionInput = { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "markerAndContent", + content: "# A\nx", + }; + expect(withDefaultScope(explicit).scope).toBe("markerAndContent"); + }); +}); From f89ce67c010ea6df700c266ee83e04b1d718010e Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 16:03:35 -0500 Subject: [PATCH 20/73] Set version to 2.0.0 for the 2.0 development line The version_2.0 branch still carried the published 1.x version (1.1.0), giving it an npm package identity identical to the released package. A consumer that depends on both engines at once (obsidian-local-rest-api, during its migration) sees two packages with the same name@version and TypeScript dedupes them, type-checking 2.0 imports against the 1.x declarations. Bumping to 2.0.0 gives this line a distinct identity and matches the eventual `^2.0.0` npm alias. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7bad31a..d02d7c9 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "typescript": "^5.5.4" }, "name": "markdown-patch", - "version": "1.1.0", + "version": "2.0.0", "main": "./dist/index.js", "scripts": { "build": "tsc", From bfe6e6d3ed662270992137cb7b46d00a6c8e3559 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 17:41:31 -0500 Subject: [PATCH 21/73] Export map/model surface and add readTarget helper The obsidian-local-rest-api 2.0 migration needs to expose the 2.0 document map (heading addresses as arrays plus the content-hash version token) and to perform targeted section reads through the 2.0 model. projectMap, buildModel, and the PublicMap type were internal-only, and there was no public read path. Export projectMap/buildModel/PublicMap and add readTarget(document, target): the read-side mirror of patch(). It resolves the same (targetType, target) address a patch instruction carries and returns the section body (headings and blocks) or parsed value (frontmatter), throwing TargetNotFoundError otherwise. Heading reads use headingContentRange for 1.x content-span parity. Co-Authored-By: Claude Opus 4.8 --- src/index.ts | 5 +++ src/read.ts | 48 ++++++++++++++++++++++++++++ src/tests/read.test.ts | 71 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 src/read.ts create mode 100644 src/tests/read.test.ts diff --git a/src/index.ts b/src/index.ts index e6bde83..de981b7 100755 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,11 @@ export * from "./types.js"; // --- 2.0 engine ---------------------------------------------------------- export { patch } from "./engine.js"; +export { buildModel } from "./model.js"; +export { projectMap } from "./projection.js"; +export type { PublicMap } from "./projection.js"; +export { readTarget } from "./read.js"; +export type { ReadTarget, ReadResult } from "./read.js"; export { EngineError, InvalidCellError, diff --git a/src/read.ts b/src/read.ts new file mode 100644 index 0000000..0e25a07 --- /dev/null +++ b/src/read.ts @@ -0,0 +1,48 @@ +/** + * Targeted reads over the 2.0 model. The mirror image of {@link patch}: an + * address (the same `(targetType, target)` pair a patch instruction carries) + * resolves to a node, and the node's addressable value comes back. Headings and + * blocks yield their content as a string; frontmatter yields the parsed value. + */ + +import { buildModel } from "./model.js"; +import { resolveTarget, Addressed } from "./resolve.js"; +import { headingContentRange, blockContentRange } from "./ranges.js"; +import { TargetNotFoundError } from "./instructions.js"; + +/** The address of a read: the same addressing subset a patch instruction uses. */ +export type ReadTarget = Addressed; + +/** The result of {@link readTarget}: markdown text, or a parsed frontmatter value. */ +export type ReadResult = + | { kind: "heading" | "block"; content: string } + | { kind: "frontmatter"; value: unknown }; + +/** + * Resolve `target` against `document` and return the addressed value. For a + * heading the content span is the whole section body (subsections included), + * matching {@link headingContentRange}; for a block it is the block's text; for + * frontmatter it is the parsed value of the key. Throws {@link TargetNotFoundError} + * when the address does not resolve. + */ +export const readTarget = (document: string, target: ReadTarget): ReadResult => { + const model = buildModel(document); + const resolved = resolveTarget(model, target); + if (!resolved) { + throw new TargetNotFoundError( + `Target not found: ${target.targetType} ${JSON.stringify(target.target)}` + ); + } + switch (resolved.kind) { + case "heading": { + const range = headingContentRange(resolved.section); + return { kind: "heading", content: document.slice(range.start, range.end) }; + } + case "block": { + const range = blockContentRange(resolved.block); + return { kind: "block", content: document.slice(range.start, range.end) }; + } + case "frontmatter": + return { kind: "frontmatter", value: resolved.entry.value }; + } +}; diff --git a/src/tests/read.test.ts b/src/tests/read.test.ts new file mode 100644 index 0000000..da87a88 --- /dev/null +++ b/src/tests/read.test.ts @@ -0,0 +1,71 @@ +import { readTarget } from "../read"; +import { TargetNotFoundError } from "../instructions"; + +const DOC = + "---\n" + + "title: Draft\n" + + "tags:\n" + + "- a\n" + + "- b\n" + + "---\n\n" + + "# Overview\n\n" + + "The thesis. ^thesis\n\n" + + "## Details\n\n" + + "Nested body.\n\n" + + "# Other\n\n" + + "Elsewhere.\n"; + +describe("readTarget", () => { + test("a heading yields its whole section body, subsections included", () => { + const result = readTarget(DOC, { targetType: "heading", target: ["Overview"] }); + expect(result.kind).toBe("heading"); + if (result.kind !== "frontmatter") { + expect(result.content).toContain("The thesis."); + expect(result.content).toContain("## Details"); + expect(result.content).toContain("Nested body."); + expect(result.content).not.toContain("Elsewhere."); + } + }); + + test("a nested heading is addressed by its path array", () => { + const result = readTarget(DOC, { + targetType: "heading", + target: ["Overview", "Details"], + }); + if (result.kind !== "frontmatter") { + expect(result.content).toContain("Nested body."); + expect(result.content).not.toContain("The thesis."); + } + }); + + test("a block id yields its text", () => { + const result = readTarget(DOC, { targetType: "block", target: "thesis" }); + expect(result.kind).toBe("block"); + if (result.kind !== "frontmatter") { + expect(result.content).toContain("The thesis."); + } + }); + + test("a frontmatter key yields its parsed value", () => { + expect(readTarget(DOC, { targetType: "frontmatter", target: "title" })).toEqual({ + kind: "frontmatter", + value: "Draft", + }); + expect(readTarget(DOC, { targetType: "frontmatter", target: "tags" })).toEqual({ + kind: "frontmatter", + value: ["a", "b"], + }); + }); + + test("an unresolvable target throws TargetNotFoundError", () => { + expect(() => + readTarget(DOC, { targetType: "heading", target: ["Nope"] }) + ).toThrow(TargetNotFoundError); + expect(() => + readTarget(DOC, { targetType: "block", target: "missing" }) + ).toThrow(TargetNotFoundError); + expect(() => + readTarget(DOC, { targetType: "frontmatter", target: "absent" }) + ).toThrow(TargetNotFoundError); + }); +}); From 48f8f4a0af8de564e6996b79f8a696213033fd11 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 18:34:12 -0500 Subject: [PATCH 22/73] Document the instruction engine as the library's API The README and both typedoc guide pages still described only applyPatch and the 1.x getDocumentMap, so every documented example used a ::-joined target string, a literal-#-levels content model, and a targetScope that stopped at the next heading. None of that describes what this branch actually ships, and nothing in the docs mentioned patch, readTarget, buildModel/projectMap, deletes, moves, warnings, or ifMatch. Rewrote the docs around the operation-scope-target model as the API a new reader should learn, and marked applyPatch and getDocumentMap @deprecated with pointers to their replacements. The 1.x surface is now documented in one "Deprecated: the 1.x API" section at the end of the README, carrying a field-by-field migration table and calling out the two behavioral changes that silently alter results rather than erroring: heading levels are now relative to the edited span, and a heading's content scope now covers its whole subtree. Every documented example was executed against the engine and the shown output is its real result -- including the blank-line behavior, which is why the overview's content string carries a deliberate leading newline. Two things this does not resolve, both left for follow-up: the mdpatch CLI still drives the 1.x engine, which the CLI section now states plainly rather than implying parity; and typedoc links from project documents needed Reference.-qualified names to resolve. Co-Authored-By: Claude Opus 4.8 --- README.md | 256 ++++++++++++++++++++++++++++++++-------------- pages/how_to.md | 157 ++++++++++++++++++++++------ pages/overview.md | 31 +++--- src/map.ts | 6 ++ src/patch.ts | 5 + 5 files changed, 335 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 4779e1e..92bb8e4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Make targeted, structure-aware edits to Markdown documents — without `sed`. -Instead of treating a document as a blob of text, `markdown-patch` understands its structure (headings, block references, frontmatter) and lets you append, prepend, or replace content at a specific location within it. +Instead of treating a document as a blob of text, `markdown-patch` understands its structure (headings, block references, frontmatter) and lets you edit a specific location within it. Available as both a **CLI tool** (`mdpatch`) and a **TypeScript/JavaScript library**. @@ -16,12 +16,35 @@ npm install markdown-patch The `mdpatch` binary is included and available after install. -## Quick start +## The model -Given a document `notes.md`: +Every edit is one **operation** applied to a **scope** of a **target** node. -```markdown ---- +- **`targetType`** — `heading`, `block`, or `frontmatter`. +- **`target`** — for a heading, an array of heading texts from the top level down (`["Meeting Notes", "Action Items"]`), or `null`/`[]` for the document root; for a block, the bare id without `^`; for a frontmatter field, the key. +- **`operation`** — `replace`, `prepend`, `append`, or `delete`. +- **`scope`** (optional, defaults to `content`): + - `content` — the node's body. For a heading, that's its whole subtree *below* the heading line. + - `marker` — the label only: a heading line, a block `^id`, or a frontmatter key. `replace` renames it. + - `markerAndContent` — the whole node/subtree. `prepend`/`append` insert a *sibling* before/after it. + - `parent` — a heading's place in the tree. Valid only with `replace`, and carries a `destination` (a **move**). + +The payload rides in exactly one field, chosen by what it is: + +| Field | Type | Used for | +|---|---|---| +| `content` | `string` | Heading and block bodies/labels, and frontmatter key renames | +| `value` | `unknown` (JSON) | Frontmatter values | +| `destination` | `ParentSpec` | Where a moved heading lands | + +Not every combination is meaningful. `prepend @ parent`, or any `parent` scope on a block or frontmatter target, is not part of the algebra and is rejected with an `InvalidCellError`. + +## Library usage + +```typescript +import { patch } from "markdown-patch"; + +const document = `--- status: in-progress --- @@ -30,28 +53,144 @@ status: in-progress ## Action Items - Follow up with design team +`; + +const { document: patched, warnings } = patch(document, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], + operation: "append", + content: "- Send the report\n", +}); ``` -Append a new item under `Action Items`: +`patch` returns `{ document, warnings }` — it does not mutate its input. -```sh -echo "- Send the report" | mdpatch patch append heading "Meeting Notes::Action Items" notes.md +### Relative heading levels + +Heading `#`-counts inside a `content` string are *relative* to the span being edited, so you never count `#`s yourself. Appending `# Notes from the call` to the content of the level-1 `Meeting Notes` writes it as `## Notes from the call` — a direct child: + +```typescript +patch(document, { + targetType: "heading", + target: ["Meeting Notes"], + operation: "append", + content: "# Notes from the call\n\nSome detail.\n", +}); ``` -Replace the `status` frontmatter field: +Under `markerAndContent` (or a sibling insert) the same content lands at the target's own level instead. A level rebased past `######` (h6) is still written, but `warnings` will contain a `heading-depth-overflow` entry. -```sh -echo '"done"' | mdpatch patch replace frontmatter status notes.md +### Blank lines are not synthesized + +A blank-line separator at the target boundary is preserved only if one was already there — it is never inserted. In the example above the result is `- Follow up with design team\n## Notes from the call`, with no gap. If you want a blank line, include it yourself: end your `content` with `\n\n` for `append`, or start it with `\n\n` for `prepend`. + +### Frontmatter + +Frontmatter payloads are JSON, so they ride in `value`: + +```typescript +patch(document, { + targetType: "frontmatter", + target: "status", + operation: "replace", + value: "done", +}); ``` -Not sure what targets exist in a document? Use `print-map`: +`prepend`/`append` merge rather than overwrite — list concat, dict merge, string concat. Appending `["beta"]` to a `tags` of `["alpha"]` yields `["alpha", "beta"]`. Add `createTargetIfMissing: true` to create a field that may not exist yet. -```sh -mdpatch print-map notes.md +To rename a key, use `scope: "marker"` — the new key name is a string, so it rides in `content`, not `value`. + +### Renaming, deleting, and moving + +Rename a heading with the `marker` scope. Supply just the text — the engine keeps the level, so no `#`s are needed: + +```typescript +patch(document, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], + operation: "replace", + scope: "marker", + content: "Follow-ups", +}); +``` + +`delete` empties the `content` scope, removes the whole subtree (`markerAndContent`), or dissolves just the heading line while keeping its body (`marker`). + +A move is `replace @ parent` with a `destination`: + +```typescript +patch(document, { + targetType: "heading", + target: ["A", "Details"], + operation: "replace", + scope: "parent", + destination: { parent: ["B"], place: "last" }, +}); +``` + +`place` may be `"first"`, `"last"`, `{ before: }`, or `{ after: }`. Use `parent: null` to move to the document root. The section is re-levelled to fit its new home. + +### Inspecting a document + +`buildModel` parses a document; `projectMap` projects it into the public map of what is addressable: + +```typescript +import { buildModel, projectMap } from "markdown-patch"; + +const map = projectMap(buildModel(document)); +// { +// version: "c23234", +// frontmatterFields: ["status"], +// headings: [["Meeting Notes"], ["Meeting Notes", "Action Items"]], +// blocks: [] +// } +``` + +Each `headings` entry is an array whose length is that heading's level, so `["Meeting Notes", "Action Items"]` is two deep. Pass one straight back as a `target`. A `null` element marks a skipped level; `""` is a genuinely empty heading. + +`readTarget` is the mirror image of `patch` — the same `(targetType, target)` address, read instead of written: + +```typescript +import { readTarget } from "markdown-patch"; + +readTarget(document, { targetType: "heading", target: ["Meeting Notes", "Action Items"] }); +// { kind: "heading", content: "\n- Follow up with design team\n" } + +readTarget(document, { targetType: "frontmatter", target: "tags" }); +// { kind: "frontmatter", value: ["alpha"] } ``` +### Optimistic concurrency + +Pass `ifMatch` with the `version` token from the map you planned against. If the document changed since, the patch throws `PreconditionFailedError` and nothing is modified — rebuild the map and retry: + +```typescript +patch(document, { + targetType: "frontmatter", + target: "status", + operation: "replace", + value: "done", + ifMatch: map.version, +}); +``` + +### Errors + +All failures extend `EngineError`: + +| Error | Raised when | +|---|---| +| `InvalidCellError` | The operation×scope combination is not part of the algebra | +| `TargetNotFoundError` | The address does not resolve (and `createTargetIfMissing` was not set) | +| `PreconditionFailedError` | The `ifMatch` version did not match | +| `ContentPreexistsError` | `rejectIfContentPreexists` was set and the value was already there | +| `MergeError` | A frontmatter merge hit a type mismatch | + ## CLI reference +> **Note:** the `mdpatch` CLI currently drives the deprecated 1.x engine described under [Deprecated: the 1.x API](#deprecated-the-1x-api). Its addressing is `::`-joined rather than an array, and it has no access to `delete`, moves, or `ifMatch`. CLI support for the model above is still to come; use the library for anything the 1.x surface cannot express. + ### `mdpatch patch` Apply a single patch operation. @@ -62,7 +201,7 @@ mdpatch patch [options] - `` — `append`, `prepend`, or `replace` - `` — `heading`, `block`, or `frontmatter` -- `` — the target address (see below) +- `` — the target address, `::`-joined for nested headings - `` — file to modify (patched in-place by default) Options: @@ -73,6 +212,11 @@ Options: | `-o, --output ` | Write result to a file instead of patching in-place; use `-` for stdout | | `-d, --delimiter ` | Heading path delimiter (default: `::`) | +```sh +echo "- Send the report" | mdpatch patch append heading "Meeting Notes::Action Items" notes.md +echo '"done"' | mdpatch patch replace frontmatter status notes.md +``` + ### `mdpatch apply` Apply one or more patch instructions from a JSON patch file. @@ -99,69 +243,25 @@ Show all patchable targets discovered in a document, useful for finding the righ mdpatch print-map [regex] ``` -## Targets - -### Headings - -Address a section by its heading path, delimited by `::` (or a custom delimiter). Nested headings use the full path: - -```sh -# Target the top-level "Overview" section -mdpatch patch append heading "Overview" notes.md - -# Target a nested heading -mdpatch patch append heading "Meeting Notes::Action Items" notes.md -``` - -### Block references - -Address a paragraph, table, or other block by its Obsidian block ID (e.g. `^abc123`): - -```sh -echo "New row content" | mdpatch patch append block "abc123" notes.md -``` - -When the target block is a Markdown table and content type is `application/json`, rows can be appended or prepended as JSON arrays. - -### Frontmatter fields - -Address a YAML frontmatter key by name. Content is treated as JSON: - -```sh -# Set a scalar -echo '"done"' | mdpatch patch replace frontmatter status notes.md - -# Append to a list -echo '"new-tag"' | mdpatch patch append frontmatter tags notes.md -``` - -## Library usage +## Deprecated: the 1.x API -```typescript -import { applyPatch, getDocumentMap } from "markdown-patch"; +`applyPatch` and `getDocumentMap` are the previous generation of this library. They still work and are still exported, but they are deprecated and will be removed in a future major release. -const document = `# My Note\n\n## Tasks\n\n- Buy milk\n`; +The 1.x API spread its addressing across a `::`-joined `target` string with a separate `targetDelimiter`, offered no `delete` operation, no moves, and no `version` token. To migrate, switch to `patch` and move each field across: -const patched = applyPatch(document, { - operation: "append", - targetType: "heading", - target: ["My Note", "Tasks"], - content: "- Write tests\n", -}); -``` - -`getDocumentMap` parses a document and returns its structure — useful for inspecting what headings, blocks, and frontmatter fields are available before patching. - -### Patch instruction options - -| Option | Type | Description | -|---|---|---| -| `operation` | `"append" \| "prepend" \| "replace"` | What to do | -| `targetType` | `"heading" \| "block" \| "frontmatter"` | What to target | -| `target` | `string \| string[]` | Target address (array for heading paths) | -| `content` | `string` | Content to apply | -| `contentType` | `"text/markdown" \| "application/json"` | Defaults to `text/markdown` | -| `targetScope` | `"content" \| "marker" \| "markerAndContent"` | Heading and block only. Defaults to `"content"`. `"marker"` targets only the heading line or block ID (useful for renaming). `"markerAndContent"` targets the full range covering both. | -| `createTargetIfMissing` | `boolean` | Create the heading or block if it doesn't exist | -| `rejectIfContentPreexists` | `boolean` | Reject the patch (with `ContentAlreadyPreexistsInTarget`) if the supplied content is already present in the target. Ignored for `replace` operations. | -| `trimTargetWhitespace` | `boolean` | Trim whitespace from the target boundary before joining | +| 1.x (`applyPatch`) | Current (`patch`) | +|---|---| +| `operation: "append"` | `operation: "append"` (now also `"delete"`) | +| `targetType: "heading"` | `targetType: "heading"` | +| `target: "A::B"` (+ `targetDelimiter`) | `target: ["A", "B"]` (a real array — no delimiter) | +| `targetScope: "content"` | `scope: "content"` (adds `"parent"` for moves) | +| `content: "..."` | `content: "..."` for headings/blocks, `value: ` for frontmatter | +| `createTargetIfMissing: true` | `createTargetIfMissing: true` | +| `rejectIfContentPreexists: true` | `rejectIfContentPreexists: true` | +| `trimTargetWhitespace` | *(dropped; the engine owns boundary whitespace)* | +| `getDocumentMap(doc)` | `projectMap(buildModel(doc))` | + +Two behavioral differences to watch for when migrating: + +- **Heading levels are now relative.** 1.x took the `#`s in your content literally; the current engine rebases them against the span being edited. +- **Heading `content` scope covers the whole subtree.** In 1.x it stopped at the next heading of any level. diff --git a/pages/how_to.md b/pages/how_to.md index 4b4ce8e..cd7aae1 100644 --- a/pages/how_to.md +++ b/pages/how_to.md @@ -4,53 +4,150 @@ group: Documents category: Guides --- -# Using as a library +All examples below assume this document: -```ts -import {PatchInstruction, applyPatch} from "markdown-patch" +```typescript +const myDocument = `--- +status: in-progress +tags: + - alpha +--- + +# Meeting Notes + +## Action Items + +- Follow up with design team +`; +``` + +# Add content below a heading + +```typescript +import { patch } from "markdown-patch"; -const myDocument = ` -# Noise Floor +const { document } = patch(myDocument, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], + operation: "append", + content: "- Send the report\n", +}); +``` + +The heading target is an array of heading texts from the top level down, so a heading whose text contains `::` needs no escaping. `prepend` and `replace` take the same shape. -- Some content +Note that the heading line itself is not part of the `content` scope. When you `replace` a heading's content, supply only the body — including the heading line would duplicate it. -# Discoveries +# Rename a heading -# Events +Use the `marker` scope, which addresses the label rather than the body. Supply just the text; the engine preserves the level: -- Checked out of my hotel -- Caught the flight home +```typescript +patch(myDocument, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], + operation: "replace", + scope: "marker", + content: "Follow-ups", +}); +``` -` +# Delete a section -const instruction: PatchInstruction { - operation: "append", - targetType: "heading", - target: "Discoveries", - content: "\n## My discovery\nI discovered a thing\n", -} +`delete` means something different in each scope: it empties the body (`content`), removes the heading and everything under it (`markerAndContent`), or dissolves just the heading line while keeping its body in place (`marker`). -console.log( - applyPatch(myDocument, instruction) -) +```typescript +patch(myDocument, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], + operation: "delete", + scope: "markerAndContent", +}); ``` -and you'll see the output: +# Move a section -```markdown -# Noise Floor +A move is `replace` applied to the `parent` scope, carrying a `destination`: -- Some content +```typescript +patch(myDocument, { + targetType: "heading", + target: ["A", "Details"], + operation: "replace", + scope: "parent", + destination: { parent: ["B"], place: "last" }, +}); +``` -# Discoveries +`place` accepts `"first"`, `"last"`, `{ before: }`, or `{ after: }`; `parent: null` moves the section to the document root. The section is re-levelled to fit wherever it lands. -## My discovery -I discovered a thing +# Set or merge a frontmatter field -# Events +Frontmatter payloads are JSON rather than markdown, so they travel in `value`: -- Checked out of my hotel -- Caught the flight home +```typescript +patch(myDocument, { + targetType: "frontmatter", + target: "status", + operation: "replace", + value: "done", +}); +``` + +`append` and `prepend` merge instead of overwriting — list concat, dict merge, string concat — so appending `["beta"]` to `tags` yields `["alpha", "beta"]`: +```typescript +patch(myDocument, { + targetType: "frontmatter", + target: "tags", + operation: "append", + value: ["beta"], + createTargetIfMissing: true, +}); ``` +There is no "remove one item" operation. To drop a single list entry, read the field, filter it, and replace the whole value. + +# Find out what a document has to target + +```typescript +import { buildModel, projectMap } from "markdown-patch"; + +const map = projectMap(buildModel(myDocument)); +// { +// version: "c23234", +// frontmatterFields: ["status", "tags"], +// headings: [["Meeting Notes"], ["Meeting Notes", "Action Items"]], +// blocks: [] +// } +``` + +Each `headings` entry can be passed straight back as a `target`, and its length is the heading's level. + +# Read a target instead of writing it + +{@link Reference.readTarget} takes the same address a patch instruction carries: + +```typescript +import { readTarget } from "markdown-patch"; + +readTarget(myDocument, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], +}); +// { kind: "heading", content: "\n- Follow up with design team\n" } +``` + +# Make an edit conditional on the document not having changed + +Pass the `version` from the map you planned against as `ifMatch`. If the document has moved on, the patch throws `PreconditionFailedError` and leaves it untouched: + +```typescript +patch(myDocument, { + targetType: "frontmatter", + target: "status", + operation: "replace", + value: "done", + ifMatch: map.version, +}); +``` diff --git a/pages/overview.md b/pages/overview.md index cbbe8ab..b3bd6c0 100644 --- a/pages/overview.md +++ b/pages/overview.md @@ -19,7 +19,9 @@ You can install the package via `npm`: npm install markdown-patch ``` -And if you were to create a document named `document.md` with the following content: +Every edit is one **operation** (`replace`, `prepend`, `append`, `delete`) applied to a **scope** (`content`, `marker`, `markerAndContent`, `parent`) of a **target** node — a heading, a block reference, or a frontmatter field. + +Given a document named `document.md`: ```markdown # Noise Floor @@ -32,21 +34,22 @@ And if you were to create a document named `document.md` with the following cont - Checked out of my hotel - Caught the flight home - ``` -Then you can use the `patch` or `apply` subcommands to alter the document. For example, the following will add a new heading below the heading "Discoveries": +You can add a subsection below "Discoveries" like so: -```bash -mdpatch patch append heading Discoveries ./document.md - -## My discovery -I discovered a thing +```typescript +import { patch } from "markdown-patch"; - +const { document: patched } = patch(document, { + targetType: "heading", + target: ["Discoveries"], + operation: "append", + content: "\n# My discovery\n\nI discovered a thing\n", +}); ``` -Your final document will then look like: +Note that the content says `#`, not `##`. Heading levels inside a `content` string are *relative* to the span being edited, so a single `#` becomes a direct child of the target and you never have to count `#`s to match the surrounding document. The result: ```markdown # Noise Floor @@ -56,13 +59,17 @@ Your final document will then look like: # Discoveries ## My discovery + I discovered a thing # Events - Checked out of my hotel - Caught the flight home - ``` -See `--help` for more insight into what commands are available. +The leading `\n` in the content above is deliberate: a blank-line separator at the target boundary is preserved only if one was already there, and is never synthesized for you. + +See {@link Reference.patch} for the full instruction shape, {@link Reference.readTarget} for the read-side mirror of the same addressing, and {@link Reference.projectMap} for discovering what a document has to target. + +> **Note:** {@link Reference.applyPatch} and {@link Reference.getDocumentMap} are the deprecated 1.x API. They still work, but new code should use {@link Reference.patch}; see the README for the migration table. diff --git a/src/map.ts b/src/map.ts index ab87f07..3410dd4 100644 --- a/src/map.ts +++ b/src/map.ts @@ -251,6 +251,12 @@ function preProcess(document: string): PreprocessedDocument { }; } +/** + * @deprecated Use {@link buildModel} with {@link projectMap} instead. This is + * the 1.x map: heading paths come back as `::`-joined strings, block ids carry + * a leading `^`, and there is no `version` token for optimistic concurrency. It + * will be removed in a future major release. + */ export const getDocumentMap = (document: string): DocumentMap => { const { frontmatter, contentOffset, content } = preProcess(document); diff --git a/src/patch.ts b/src/patch.ts index 5296038..86a6ea0 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -657,6 +657,11 @@ function regenerateDocumentWithFrontmatter( /** * Applies a patch to the specified document. * + * @deprecated Use {@link patch} instead. This is the 1.x engine, kept for + * backwards compatibility; it takes a {@link PatchInstruction} with a + * `::`-joined heading target and no scope algebra, and it will be removed in a + * future major release. See the README's migration table for the field mapping. + * * @param document The document to apply the patch to. * @param instruction The patch to apply. * @returns The patched document From c8a77a42c55863b7acdb484f7930503f6a9699fe Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 18:41:09 -0500 Subject: [PATCH 23/73] Clarify what markerAndContent covers and how it re-levels The scope was described as "the whole node/subtree", which omits the thing that actually distinguishes it from content: the marker is inside the edited span, so for a heading target a replace rewrites the heading line itself rather than just its body. Documented the level behavior, confirmed by running each case against the engine: content headings are rebased to the target's own level and internal nesting is preserved, so replacing a ## section with "# New\n\n## Child" yields ## New and ### Child. Added the footgun this implies -- content carrying no heading at all dissolves the section into a plain paragraph, since the heading line was part of what got replaced. Co-Authored-By: Claude Opus 4.8 --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 92bb8e4..eeb0089 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Every edit is one **operation** applied to a **scope** of a **target** node. - **`scope`** (optional, defaults to `content`): - `content` — the node's body. For a heading, that's its whole subtree *below* the heading line. - `marker` — the label only: a heading line, a block `^id`, or a frontmatter key. `replace` renames it. - - `markerAndContent` — the whole node/subtree. `prepend`/`append` insert a *sibling* before/after it. + - `markerAndContent` — the marker *and* the body together: for a heading, its heading line plus everything beneath it. Unlike `content`, the heading line is inside the edited span, so a `replace` here rewrites the heading itself. `prepend`/`append` insert a *sibling* before/after it. - `parent` — a heading's place in the tree. Valid only with `replace`, and carries a `destination` (a **move**). The payload rides in exactly one field, chosen by what it is: @@ -78,7 +78,9 @@ patch(document, { }); ``` -Under `markerAndContent` (or a sibling insert) the same content lands at the target's own level instead. A level rebased past `######` (h6) is still written, but `warnings` will contain a `heading-depth-overflow` entry. +Under `markerAndContent` (or a sibling insert) the same content lands at the target's own level instead. Nesting inside your content is preserved as you wrote it, so replacing a `##` section with `# New\n\n## Child` yields `## New` and `### Child`. A level rebased past `######` (h6) is still written, but `warnings` will contain a `heading-depth-overflow` entry. + +Because the heading line is part of the `markerAndContent` span, a `replace` whose content has *no* heading removes it — the section is dissolved into a plain paragraph. Include a leading `#` (at any depth; it is rebased for you) to keep it a heading. ### Blank lines are not synthesized From 4f84d892ce5e8e4c151cb1253f5563dd5b154b7a Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 19:14:30 -0500 Subject: [PATCH 24/73] Correct the documented whitespace behavior The "Blank lines are not synthesized" section was wrong in three ways, found by running its own advice against the engine. It told readers to end content with \n\n for append and start it with \n\n for prepend. Neither works: a trailing \n\n on an append at the end of a document is normalized away entirely, and a leading \n\n on a prepend produces two blank lines rather than one. The correct separator in both cases is a single leading newline. Its framing was also misleading. Saying a blank line "is preserved only if one was already there" suggests the engine inspects the boundary, which invites exactly the wrong prediction: prepending into a section whose heading is followed by a blank line still lands flush against the heading, because that blank line belongs to the body and is pushed below the inserted text rather than kept above it. Replaced it with the rule the engine actually implements -- content is spliced verbatim at one edge of the target's span and the engine contributes no whitespace -- and worked each operation through a single example. Added docs.whitespace.test.ts pinning the literal strings the README quotes, so the two cannot drift apart again silently. Co-Authored-By: Claude Opus 4.8 --- README.md | 20 +++++++- pages/overview.md | 2 +- src/tests/docs.whitespace.test.ts | 78 +++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 src/tests/docs.whitespace.test.ts diff --git a/README.md b/README.md index eeb0089..50f521e 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,25 @@ Under `markerAndContent` (or a sibling insert) the same content lands at the tar Because the heading line is part of the `markerAndContent` span, a `replace` whose content has *no* heading removes it — the section is dissolved into a plain paragraph. Include a leading `#` (at any depth; it is rebased for you) to keep it a heading. -### Blank lines are not synthesized +### Whitespace is spliced verbatim -A blank-line separator at the target boundary is preserved only if one was already there — it is never inserted. In the example above the result is `- Follow up with design team\n## Notes from the call`, with no gap. If you want a blank line, include it yourself: end your `content` with `\n\n` for `append`, or start it with `\n\n` for `prepend`. +Your `content` is inserted exactly as written at one edge of the target's span; the engine adds no whitespace of its own. For a heading, that span begins immediately *after* the heading line and ends after the last line of its subtree. So given: + +```markdown +# One + +body of one +``` + +- `prepend` lands flush against the heading line → `# One\nX\n\nbody of one\n` +- `append` lands flush against the section's last line → `# One\n\nbody of one\nX\n` +- `replace` clears the whole span, blank line included → `# One\nX\n` + +In all three cases **a leading `\n` in your content is what buys you a blank line before it**. Passing `"\nX\n"` instead gives `# One\n\nX\n\nbody of one\n`, `# One\n\nbody of one\n\nX\n`, and `# One\n\nX\n` respectively. + +Note that this is a *leading* newline even for `append`: the gap you usually want is between the existing text and yours, and that edge comes first. Trailing newlines control the gap *after* your content, and are trimmed at the very end of a document — so padding the end of an `append` at the end of a file does nothing. + +The case that most often surprises: prepending into a section whose heading is already followed by a blank line still yields `# One\nX`, with no gap. That blank line is part of the body, not of the boundary, so it is pushed below your text rather than kept above it. ### Frontmatter diff --git a/pages/overview.md b/pages/overview.md index b3bd6c0..965981e 100644 --- a/pages/overview.md +++ b/pages/overview.md @@ -68,7 +68,7 @@ I discovered a thing - Caught the flight home ``` -The leading `\n` in the content above is deliberate: a blank-line separator at the target boundary is preserved only if one was already there, and is never synthesized for you. +The leading `\n` in the content above is deliberate. Content is spliced in exactly as written at the edge of the target's span, and the engine adds no whitespace of its own — without that newline, `## My discovery` would sit flush against the line above it. See the README for the full whitespace rules. See {@link Reference.patch} for the full instruction shape, {@link Reference.readTarget} for the read-side mirror of the same addressing, and {@link Reference.projectMap} for discovering what a document has to target. diff --git a/src/tests/docs.whitespace.test.ts b/src/tests/docs.whitespace.test.ts new file mode 100644 index 0000000..8bf64b4 --- /dev/null +++ b/src/tests/docs.whitespace.test.ts @@ -0,0 +1,78 @@ +/** + * Pins the whitespace contract the README documents under "Whitespace is + * spliced verbatim". Content is spliced in exactly as written at one edge of + * the target's span and the engine contributes no whitespace of its own, so a + * leading `\n` is what produces a blank line before the inserted text — for + * `append` as much as for `prepend`. + * + * These expectations are the literal strings quoted in the docs. If one of + * them changes, the documentation is wrong and must change with it. + */ + +import { patch } from "../engine.js"; + +const doc = `# One + +body of one +`; + +const run = (operation: "append" | "prepend" | "replace", content: string) => + patch(doc, { targetType: "heading", target: ["One"], operation, content }).document; + +describe("documented whitespace behavior", () => { + describe("content with no leading newline lands flush against its neighbor", () => { + it("prepend butts against the heading line", () => { + expect(run("prepend", "X\n")).toBe("# One\nX\n\nbody of one\n"); + }); + + it("append butts against the section's last line", () => { + expect(run("append", "X\n")).toBe("# One\n\nbody of one\nX\n"); + }); + + it("replace clears the span, blank line included", () => { + expect(run("replace", "X\n")).toBe("# One\nX\n"); + }); + }); + + describe("a leading newline buys a blank line before the content", () => { + it("prepend", () => { + expect(run("prepend", "\nX\n")).toBe("# One\n\nX\n\nbody of one\n"); + }); + + it("append", () => { + expect(run("append", "\nX\n")).toBe("# One\n\nbody of one\n\nX\n"); + }); + + it("replace", () => { + expect(run("replace", "\nX\n")).toBe("# One\n\nX\n"); + }); + }); + + it("a blank line already following a heading belongs to the body, not the boundary", () => { + // The document is well-spaced, but prepending still lands flush against the + // heading: the existing blank line is pushed below the inserted text. + expect(run("prepend", "X\n")).toBe("# One\nX\n\nbody of one\n"); + }); + + it("trailing padding survives mid-document but is trimmed at end of document", () => { + const midDoc = `# One + +body of one + +# Two + +body of two +`; + expect( + patch(midDoc, { + targetType: "heading", + target: ["One"], + operation: "append", + content: "X\n\n", + }).document + ).toBe("# One\n\nbody of one\nX\n\n# Two\n\nbody of two\n"); + + // At the end of the document the same trailing blank line is normalized away. + expect(run("append", "X\n\n")).toBe("# One\n\nbody of one\nX\n"); + }); +}); From 22821cac7aa15b518038e1e219414c6a8588fde7 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Sun, 19 Jul 2026 19:26:58 -0500 Subject: [PATCH 25/73] Document that marker renames take plain text, never `#` characters Renaming a heading needs no knowledge of markers or heading depth: a marker-scope replace takes the new label as plain text and preserves the level. The README implied this but did not say what happens if you do pass `#` characters, which turns out to matter a lot. They are not stripped. They become part of the label, so "## New Name" renames the heading to the literal text `## New Name`. That is the exact reverse of the deprecated applyPatch, which *required* matching `#`s -- so following the old habit produces a corrupted heading rather than an error. Called this out where the migration is most likely to happen. Added docs.rename.test.ts pinning all of it, including the `#` case, so the warning cannot quietly become false. Also noted that the same instruction shape renames block ids and frontmatter keys, verified against the engine. Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +++- src/tests/docs.rename.test.ts | 67 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/tests/docs.rename.test.ts diff --git a/README.md b/README.md index 50f521e..286d86e 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ To rename a key, use `scope: "marker"` — the new key name is a string, so it r ### Renaming, deleting, and moving -Rename a heading with the `marker` scope. Supply just the text — the engine keeps the level, so no `#`s are needed: +Rename a heading with the `marker` scope. Supply just the new text — the heading keeps whatever level it had, so you never need to know its depth: ```typescript patch(document, { @@ -133,6 +133,10 @@ patch(document, { }); ``` +Do **not** include `#` characters here. They are not stripped — they become part of the heading text, so `"## Follow-ups"` renames the heading to the literal `## Follow-ups`. (The deprecated `applyPatch` required them; if you are migrating, drop them.) + +The same shape renames a block id (`targetType: "block"`, new id without `^`) or a frontmatter key (`targetType: "frontmatter"`, new key in `content`). + `delete` empties the `content` scope, removes the whole subtree (`markerAndContent`), or dissolves just the heading line while keeping its body (`marker`). A move is `replace @ parent` with a `destination`: diff --git a/src/tests/docs.rename.test.ts b/src/tests/docs.rename.test.ts new file mode 100644 index 0000000..fb5cd5e --- /dev/null +++ b/src/tests/docs.rename.test.ts @@ -0,0 +1,67 @@ +/** + * Pins the rename behavior the README documents under "Renaming, deleting, and + * moving". A `marker`-scope replace takes the new label as plain text and + * preserves the node's level, so callers never need to know a heading's depth. + * + * The `#` case matters because it is the exact reverse of the deprecated + * `applyPatch`, which *required* matching `#` characters. Here they are not + * stripped -- they land in the heading text -- so a caller migrating from the + * old API silently gets a corrupted heading rather than an error. + */ + +import { patch } from "../engine.js"; + +const doc = `--- +alpha: 1 +--- + +# Heading 1 + +## Subheading + +body + +some paragraph ^abc123 +`; + +const rename = ( + targetType: "heading" | "block" | "frontmatter", + target: string | string[], + content: string +) => + patch(doc, { + targetType, + target, + operation: "replace", + scope: "marker", + content, + } as never).document; + +describe("documented rename behavior", () => { + it("renames a heading from plain text, preserving its level", () => { + expect(rename("heading", ["Heading 1", "Subheading"], "New Name")).toContain("## New Name"); + }); + + it("leaves the heading's body untouched", () => { + expect(rename("heading", ["Heading 1", "Subheading"], "New Name")).toContain( + "## New Name\n\nbody\n" + ); + }); + + it("does not strip `#` characters -- they become part of the label", () => { + // Documented as a trap for callers migrating from applyPatch, which + // required the `#`s. If this ever starts stripping them, the README's + // warning is wrong and must be updated. + expect(rename("heading", ["Heading 1", "Subheading"], "## New Name")).toContain( + "## ## New Name" + ); + }); + + it("renames a block id, which is given bare", () => { + expect(rename("block", "abc123", "xyz789")).toContain("some paragraph ^xyz789"); + }); + + it("renames a frontmatter key, preserving its value", () => { + expect(rename("frontmatter", "alpha", "renamed")).toContain("renamed: 1"); + }); +}); From fcdd874235d16cd6e0fe50634dc61212aa018cef Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Mon, 20 Jul 2026 21:00:00 -0500 Subject: [PATCH 26/73] Address headings by containment path, not null-padded level The public heading address was a null-padded array whose length encoded the heading's level: index i held the level-(i+1) heading on the path, null marked a skipped level, and "" an empty-text heading. That put in-band grammar (level counting, null holes) into the one shape a consumer has to construct by hand, and it made the map an array of those arrays -- flat, order-dependent, and awkward to read. Replace it with the containment path: the ancestor heading texts from the top level down (["Overview", "Details"]), one entry per heading regardless of source depth. A skipped level simply does not appear, so a caller never encodes or counts levels; the engine owns depth end to end. - HeadingAddress is now string[] | null, and headingPath walks parent links top-down instead of filling a level-indexed array. - PublicMap.headings becomes a nested HeadingTree: each heading text maps to its child headings, a leaf to {}. Nesting is by containment, so a repeated sibling name collapses to its first occurrence in document order -- matching the resolver, which already resolves to the first match. A HeadingTree doc comment records this and leaves a future note to surface shadowed duplicates rather than omit them. Blocks are still collected globally, so a block under a shadowed duplicate heading stays listed and addressable by its bare id. - resolveHeading collapses its two tiers (exact padded match, then level-agnostic fallback) into a single containment-path match, since there are no longer explicit levels to disambiguate. - createHeading drops its null-filtering; the target is already the plain path. - Added headingTreePaths to enumerate every address in a tree in document order -- the walk a consumer runs to turn a map into its list of patchable heading targets. Updated projection, resolve, and symmetry tests to the new shapes, including first-wins duplicate collapse and the shadowed-block case. Co-Authored-By: Claude Opus 4.8 --- src/engine/create.ts | 12 ++--- src/index.ts | 4 +- src/instructions.ts | 13 +++-- src/projection.ts | 102 +++++++++++++++++++++++++---------- src/resolve.ts | 34 ++++-------- src/tests/projection.test.ts | 49 +++++++++++++---- src/tests/resolve.test.ts | 20 +++---- src/tests/symmetry.test.ts | 12 ++--- 8 files changed, 148 insertions(+), 98 deletions(-) diff --git a/src/engine/create.ts b/src/engine/create.ts index 36a74d8..966e09d 100644 --- a/src/engine/create.ts +++ b/src/engine/create.ts @@ -41,25 +41,23 @@ export const createHeading = ( "createTargetIfMissing for headings supports content-scope writes only" ); } - const collapsed = (instruction.target ?? []).filter( - (segment): segment is string => segment !== null - ); - if (collapsed.length === 0) { + const path = instruction.target ?? []; + if (path.length === 0) { throw new EngineError("the document root cannot be created"); } // Find the deepest existing ancestor prefix; the remaining segments are new. let ancestor = model.root; let matched = 0; - for (let length = collapsed.length - 1; length >= 1; length--) { - const resolved = resolveHeading(model, collapsed.slice(0, length)); + for (let length = path.length - 1; length >= 1; length--) { + const resolved = resolveHeading(model, path.slice(0, length)); if (resolved) { ancestor = resolved.section; matched = length; break; } } - const toCreate = collapsed.slice(matched); + const toCreate = path.slice(matched); const warnings: Warning[] = []; const parts: string[] = []; diff --git a/src/index.ts b/src/index.ts index de981b7..f5dbd97 100755 --- a/src/index.ts +++ b/src/index.ts @@ -20,8 +20,8 @@ export * from "./types.js"; export { patch } from "./engine.js"; export { buildModel } from "./model.js"; -export { projectMap } from "./projection.js"; -export type { PublicMap } from "./projection.js"; +export { projectMap, headingTreePaths } from "./projection.js"; +export type { PublicMap, HeadingTree } from "./projection.js"; export { readTarget } from "./read.js"; export type { ReadTarget, ReadResult } from "./read.js"; export { diff --git a/src/instructions.ts b/src/instructions.ts index 716490d..5ccdfc5 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -24,12 +24,15 @@ export type Scope = "content" | "marker" | "markerAndContent" | "parent"; export type TargetType = "heading" | "block" | "frontmatter"; /** - * A heading address: a null-padded or collapsed array of heading texts, or - * `null`/`[]` for the document root. A collapsed array (no `null`s) matches by - * nesting like a 1.x path; `null` explicitly marks a skipped level and `""` a - * genuinely empty-text heading. Array length is the heading's level. + * A heading address: the containment path of ancestor heading texts from the + * top level down (e.g. `["Overview", "Details"]`), or `null`/`[]` for the + * document root. The address names sections by nesting, never by heading + * depth: a level skipped in the source (an `h1` followed directly by an `h3`) + * simply does not appear, and `""` is a genuinely empty-text heading. The + * engine owns heading depth end to end, so a caller never encodes or counts + * levels. */ -export type HeadingAddress = (string | null)[] | null; +export type HeadingAddress = string[] | null; /** Where a moved section lands relative to its new parent's children. */ export type Place = diff --git a/src/projection.ts b/src/projection.ts index 2923350..4425107 100644 --- a/src/projection.ts +++ b/src/projection.ts @@ -1,62 +1,92 @@ import { DocumentModel, SectionNode } from "./model.js"; +/** + * A nested map of heading text to its child headings, mirroring the document's + * section nesting. A leaf heading maps to an empty object. The tree carries no + * heading levels: nesting is by containment, so a level skipped in the source + * (an `h1` followed directly by an `h3`) does not appear as a hole — the engine + * owns depth and a consumer never needs it. + * + * Sibling headings are keyed by text, so a repeated sibling name cannot appear + * twice: the **first** occurrence in document order wins and later same-name + * siblings (with their subtrees) are omitted, matching the resolver, which + * resolves an address to the first match in document order. To reach a heading + * shadowed by an earlier duplicate, target the next-higher heading or the + * document as a whole. + * + * future: surface shadowed duplicate headings (e.g. under a reserved section of + * the map) so they are visible and addressable rather than silently omitted. + */ +export interface HeadingTree { + [headingText: string]: HeadingTree; +} + /** * The terse, context-cheap public view of a document, derived from the - * {@link DocumentModel}. It carries no in-band grammar: headings are plain - * null-padded arrays and block references are bare ids. + * {@link DocumentModel}. It carries no in-band grammar: headings nest by + * containment in a {@link HeadingTree} and block references are bare ids. */ export interface PublicMap { /** Content-hash token; pass back as an `ifMatch` precondition. */ version: string; /** Top-level frontmatter field names, in document order. */ frontmatterFields: string[]; - /** - * One entry per heading, in document order. Each entry is an array whose - * length equals the heading's level: index `i` holds the text of the heading - * at level `i + 1` on the path to this heading, `null` where that level is - * skipped, and `""` for an empty-text heading. - */ - headings: (string | null)[][]; + /** Headings nested by containment; see {@link HeadingTree}. */ + headings: HeadingTree; /** Block reference ids, bare (no `^`), in document order. */ blocks: string[]; } /** - * The null-padded address of a heading-bearing section: an array whose length - * is the heading's level, index `i` holding the text of the level-`i+1` heading - * on the path to this node, `null` for a skipped level and `""` for an - * empty-text heading. Shared with the resolver so map addresses and target - * matching use one definition. + * The containment path of a heading-bearing section: the ancestor heading texts + * from the top level down to this node (e.g. `["Overview", "Details"]`), one + * entry per heading on the path regardless of source level. This is exactly the + * address a consumer sends back as a heading target. Shared with the resolver + * so map addresses and target matching use one definition. */ -export const headingPath = (node: SectionNode): (string | null)[] => { - const level = node.heading!.level; - const path: (string | null)[] = new Array(level).fill(null); +export const headingPath = (node: SectionNode): string[] => { + const path: string[] = []; let current: SectionNode | null = node; while (current && current.heading) { - path[current.heading.level - 1] = current.heading.text; + path.push(current.heading.text); current = current.parent; } - return path; + return path.reverse(); }; /** Project the internal model into the public map consumers receive. */ export const projectMap = (model: DocumentModel): PublicMap => { - const headings: (string | null)[][] = []; + const headings: HeadingTree = {}; const blocks: string[] = []; - const walk = (node: SectionNode): void => { - if (node.heading) { - headings.push(headingPath(node)); - } - // A section's own blocks precede its child sections in document order. + // Blocks are addressed globally by bare id, so every block is listed in + // document order — including any under a heading shadowed by a duplicate. + const collectBlocks = (node: SectionNode): void => { for (const block of node.blocks) { blocks.push(block.id); } for (const child of node.children) { - walk(child); + collectBlocks(child); } }; - walk(model.root); + collectBlocks(model.root); + + // Headings nest by containment, first-wins on a repeated sibling name. + const buildTree = (node: SectionNode, into: HeadingTree): void => { + for (const child of node.children) { + if (!child.heading) { + continue; + } + const { text } = child.heading; + if (Object.prototype.hasOwnProperty.call(into, text)) { + continue; // shadowed duplicate; see {@link HeadingTree} + } + const subtree: HeadingTree = {}; + into[text] = subtree; + buildTree(child, subtree); + } + }; + buildTree(model.root, headings); return { version: model.version, @@ -65,3 +95,21 @@ export const projectMap = (model: DocumentModel): PublicMap => { blocks, }; }; + +/** + * Enumerate every addressable heading in a {@link HeadingTree} as its + * containment-path target, in document order. This is the walk a consumer runs + * to turn a map into the list of heading addresses it can patch. + */ +export const headingTreePaths = (tree: HeadingTree): string[][] => { + const paths: string[][] = []; + const walk = (node: HeadingTree, prefix: string[]): void => { + for (const [text, children] of Object.entries(node)) { + const path = [...prefix, text]; + paths.push(path); + walk(children, path); + } + }; + walk(tree, []); + return paths; +}; diff --git a/src/resolve.ts b/src/resolve.ts index f17fd8e..b5416f5 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -1,10 +1,9 @@ /** * Turn a public target address back into the model node it names. Headings are - * matched by their null-padded address (see {@link headingPath}); a target with - * explicit levels wins over a level-agnostic collapsed one, and a collapsed - * address falls back to matching by nesting so the common "the section named X" - * case needs no level annotation. Duplicates resolve to the first match in - * document order; the `ifMatch` precondition guards against staleness. + * matched by their containment path (see {@link headingPath}) — the ancestor + * heading texts from the top level down, ignoring source depth — so a skipped + * level needs no annotation. Duplicates resolve to the first match in document + * order; the `ifMatch` precondition guards against staleness. */ import { @@ -22,13 +21,9 @@ export type ResolvedTarget = | { kind: "block"; block: BlockNode } | { kind: "frontmatter"; entry: FrontmatterEntry }; -const arrayEquals = (a: (string | null)[], b: (string | null)[]): boolean => +const arrayEquals = (a: string[], b: string[]): boolean => a.length === b.length && a.every((value, index) => value === b[index]); -/** Drop skipped levels, keeping empty-text (`""`) segments. */ -const collapse = (path: (string | null)[]): (string | null)[] => - path.filter((segment) => segment !== null); - /** Every heading-bearing section, in document order. */ const headingSections = (model: DocumentModel): SectionNode[] => { const sections: SectionNode[] = []; @@ -51,22 +46,13 @@ export const resolveHeading = ( } const sections = headingSections(model); - // Exact tier: the node's padded address equals the target as written, so an - // explicitly levelled address (`["A", null, "B"]`) selects a precise depth. - const exact = sections.find((section) => + // Match by containment path, ignoring source depth, so a plain address finds + // its section even across a skipped heading level. The first match in + // document order wins, so a repeated heading resolves to its first occurrence. + const match = sections.find((section) => arrayEquals(headingPath(section), target) ); - if (exact) { - return { kind: "heading", section: exact }; - } - - // Collapsed tier: match by nesting, ignoring levels, so a plain path still - // finds a section reached across a skipped heading level. - const wanted = collapse(target); - const collapsed = sections.find((section) => - arrayEquals(collapse(headingPath(section)), wanted) - ); - return collapsed ? { kind: "heading", section: collapsed } : null; + return match ? { kind: "heading", section: match } : null; }; /** Resolve a bare block id to its block node, or `null`. */ diff --git a/src/tests/projection.test.ts b/src/tests/projection.test.ts index deb7acf..c44619d 100644 --- a/src/tests/projection.test.ts +++ b/src/tests/projection.test.ts @@ -1,5 +1,5 @@ import { buildModel } from "../model"; -import { projectMap } from "../projection"; +import { projectMap, headingTreePaths } from "../projection"; describe("projectMap", () => { test("produces the first-pass public shape", () => { @@ -19,21 +19,20 @@ describe("projectMap", () => { const map = projectMap(buildModel(doc)); expect(map.frontmatterFields).toEqual(["status", "reviewers"]); - expect(map.headings).toEqual([ - ["Overview"], - ["Overview", null, "Known quirks"], - ["Development Logs"], - ["Development Logs", "2026-07-18"], - ["Development Logs", "2026-07-18"], - ]); + // Headings nest by containment (the skipped h2 leaves no hole), and the two + // "2026-07-18" siblings collapse to one key — first-wins. + expect(map.headings).toEqual({ + Overview: { "Known quirks": {} }, + "Development Logs": { "2026-07-18": {} }, + }); expect(map.blocks).toEqual(["thesis", "quirks"]); expect(map.version).toMatch(/^[0-9a-f]{6}$/); }); - test("null-pads skipped levels and preserves empty heading text", () => { + test("skipped levels nest by containment and empty heading text is a key", () => { const doc = "# \n\nbody\n\n#### Deep\n\ndeep\n"; const map = projectMap(buildModel(doc)); - expect(map.headings).toEqual([[""], ["", null, null, "Deep"]]); + expect(map.headings).toEqual({ "": { Deep: {} } }); }); test("version tracks content and matches the model", () => { @@ -45,8 +44,36 @@ describe("projectMap", () => { test("headings and blocks are empty for a bare document", () => { const map = projectMap(buildModel("just text, no structure\n")); - expect(map.headings).toEqual([]); + expect(map.headings).toEqual({}); expect(map.blocks).toEqual([]); expect(map.frontmatterFields).toEqual([]); }); + + test("first-wins keeps the first duplicate's subtree, not the last", () => { + const doc = + "## Log\n\n### Monday\n\nm\n\n## Log\n\n### Tuesday\n\nt\n"; + const map = projectMap(buildModel(doc)); + // The first "Log" (with Monday) wins; the second "Log" and Tuesday drop out. + expect(map.headings).toEqual({ Log: { Monday: {} } }); + }); + + test("a block under a shadowed duplicate heading is still listed", () => { + const doc = + "## Log\n\nfirst ^a\n\n## Log\n\nsecond ^b\n"; + const map = projectMap(buildModel(doc)); + // The second "Log" is omitted from the tree, but its block stays addressable. + expect(map.headings).toEqual({ Log: {} }); + expect(map.blocks).toEqual(["a", "b"]); + }); + + test("headingTreePaths enumerates every address in document order", () => { + const doc = "# A\n\n## B\n\nb\n\n### C\n\nc\n\n# D\n\nd\n"; + const paths = headingTreePaths(projectMap(buildModel(doc)).headings); + expect(paths).toEqual([ + ["A"], + ["A", "B"], + ["A", "B", "C"], + ["D"], + ]); + }); }); diff --git a/src/tests/resolve.test.ts b/src/tests/resolve.test.ts index f9aff5b..54c87a9 100644 --- a/src/tests/resolve.test.ts +++ b/src/tests/resolve.test.ts @@ -21,21 +21,15 @@ describe("resolveHeading", () => { expect(resolveHeading(model, [])?.section).toBe(model.root); }); - test("collapsed path matches by nesting", () => { + test("containment path matches by nesting, first in document order", () => { const model = buildModel(dupDoc); - // ["A","B"] exactly matches the h2 B (padded ["A","B"]), not the h3 B. + // ["A","B"] names the first "A"'s child "B" (an h2); the second "A"'s "B" + // (an h3) shares the same containment path but comes later. const r = resolveHeading(model, ["A", "B"]); expect(headingLevel(r)).toBe(2); expect(bodyOf(dupDoc, r)).toContain("body b1"); }); - test("null-padding disambiguates by level", () => { - const model = buildModel(dupDoc); - const r = resolveHeading(model, ["A", null, "B"]); - expect(headingLevel(r)).toBe(3); - expect(bodyOf(dupDoc, r)).toContain("body b2"); - }); - test("duplicate headings resolve to the first in document order", () => { const model = buildModel(dupDoc); const r = resolveHeading(model, ["A"]); @@ -48,19 +42,17 @@ describe("resolveHeading", () => { } }); - test("collapsed path spans a skipped level (garden path)", () => { + test("containment path spans a skipped level (garden path)", () => { const doc = ["# Over", "### Quirk", "x", ""].join("\n"); const model = buildModel(doc); + // The skipped h2 leaves no hole in the address; ["Over","Quirk"] resolves. expect(headingLevel(resolveHeading(model, ["Over", "Quirk"]))).toBe(3); - expect(headingLevel(resolveHeading(model, ["Over", null, "Quirk"]))).toBe(3); }); - test("empty-text heading is distinct from a skipped level", () => { + test("empty-text heading is addressable by its empty-string key", () => { const doc = ["# ", "under empty", "## Real", "deep", ""].join("\n"); const model = buildModel(doc); expect(headingLevel(resolveHeading(model, ["", "Real"]))).toBe(2); - // [null,"Real"] must NOT match ["","Real"]. - expect(resolveHeading(model, [null, "Real"])).toBeNull(); }); test("returns null when no heading matches", () => { diff --git a/src/tests/symmetry.test.ts b/src/tests/symmetry.test.ts index 0c2b8f9..11500ff 100644 --- a/src/tests/symmetry.test.ts +++ b/src/tests/symmetry.test.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "url"; import { patch } from "../engine"; import { Instruction } from "../instructions"; import { buildModel, eachSection, serializeModel } from "../model"; -import { projectMap, headingPath } from "../projection"; +import { projectMap, headingPath, headingTreePaths } from "../projection"; import { headingContentRange } from "../ranges"; const __filename = fileURLToPath(import.meta.url); @@ -15,14 +15,10 @@ const CONFORMANCE_DIR = path.join(__dirname, "conformance"); const arrayEquals = (a: unknown[], b: unknown[]): boolean => a.length === b.length && a.every((value, index) => value === b[index]); -const collapse = (path: (string | null)[]): (string | null)[] => - path.filter((segment) => segment !== null); - -const collapsedHeadings = (document: string): (string | null)[][] => - projectMap(buildModel(document)).headings.map(collapse); - const hasHeading = (document: string, wanted: string[]): boolean => - collapsedHeadings(document).some((heading) => arrayEquals(heading, wanted)); + headingTreePaths(projectMap(buildModel(document)).headings).some((heading) => + arrayEquals(heading, wanted) + ); // The top design constraint: a successful, non-overflow write leaves its target // addressable in the map derived from the result. From 130c5a5ebfb162d19f1bb683bdae57bd485679dd Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Mon, 20 Jul 2026 21:06:31 -0500 Subject: [PATCH 27/73] Publish a Zod schema for a patch instruction Downstream projects each kept their own copy of an instruction's shape: obsidian-local-rest-api hand-wrote the same field set once as its MCP `vault_patch` tool input and again as its OpenAPI `PatchInstruction` component, and neither enforced the cross-field rules the engine relies on. Nothing tied those copies to this library's types, so they drifted. Add InstructionInputSchema as the single source of truth for an instruction's shape and validity, from which those surfaces can be derived rather than restated. It is a flat object with rich field descriptions -- the only shape an MCP tool input accepts, and the shape both REST and MCP already use -- so consumers read its `.shape` to build a tool input and run the whole schema through zod-to-json-schema for their OpenAPI component. The cross-field rules a discriminated union would encode structurally are enforced by a superRefine: the target shape must match its type, the operation x scope cell must be part of the algebra (reusing isValidCell), and exactly the carrier that cell expects must be present -- `content` for a heading/block body or a rename, `value` for a frontmatter value, `destination` for a move, and nothing for a delete. This also pins down that `value` is frontmatter-only in 2.0; the 1.x table-row-via-value behavior does not exist here. patch() now validates its input against the schema at the boundary and throws a typed InvalidInstructionError, so a malformed instruction is rejected up front instead of a handler silently misreading an absent field. The hand-written InstructionInput union stays the exported type; a compile-time assignment and a runtime case per union member guard the schema against drifting from it. zod is now a runtime dependency, pinned to 3.25.76 to match the copy the consuming MCP SDK uses so the two produce structurally identical schemas. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 16 ++- package.json | 3 +- src/engine.ts | 17 +++ src/index.ts | 5 + src/instructions.ts | 8 ++ src/schema.ts | 247 +++++++++++++++++++++++++++++++++++++++ src/tests/schema.test.ts | 169 +++++++++++++++++++++++++++ 7 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 src/schema.ts create mode 100644 src/tests/schema.test.ts diff --git a/package-lock.json b/package-lock.json index 35b23a8..a4532bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,20 @@ { "name": "markdown-patch", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "markdown-patch", - "version": "1.0.0", + "version": "2.0.0", "license": "ISC", "dependencies": { "@tsconfig/node16": "^16.1.3", "chalk": "^5.3.0", "commander": "^12.1.0", "marked": "^17.0.1", - "yaml": "^2.5.1" + "yaml": "^2.5.1", + "zod": "3.25.76" }, "bin": { "mdpatch": "dist/cli.js" @@ -4903,6 +4904,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index d02d7c9..1452a7e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "chalk": "^5.3.0", "commander": "^12.1.0", "marked": "^17.0.1", - "yaml": "^2.5.1" + "yaml": "^2.5.1", + "zod": "3.25.76" }, "devDependencies": { "@types/commander": "^2.12.2", diff --git a/src/engine.ts b/src/engine.ts index c1e8d4b..0aefcbf 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -36,9 +36,11 @@ import { PreconditionFailedError, TargetNotFoundError, ContentPreexistsError, + InvalidInstructionError, assertValidCell, withDefaultScope, } from "./instructions.js"; +import { InstructionInputSchema } from "./schema.js"; import { ResolvedTarget } from "./resolve.js"; /** The subset of an instruction {@link assertValidCell} inspects. */ @@ -238,6 +240,21 @@ export const patch = ( document: string, input: InstructionInput ): PatchResult => { + // Validate the whole instruction at the boundary: field shapes, the target + // shape for its type, cell validity, and the carrier the cell expects. A + // failure here means the caller sent a malformed instruction, so surface it + // as one typed error rather than letting a handler misread an absent field. + const parsed = InstructionInputSchema.safeParse(input); + if (!parsed.success) { + throw new InvalidInstructionError( + parsed.error.issues + .map((issue) => + issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message + ) + .join("; ") + ); + } + const instruction = withDefaultScope(input); const model = buildModel(document); assertValidCell(cellOf(instruction)); diff --git a/src/index.ts b/src/index.ts index f5dbd97..53fcd75 100755 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export type { ReadTarget, ReadResult } from "./read.js"; export { EngineError, InvalidCellError, + InvalidInstructionError, TargetNotFoundError, PreconditionFailedError, ContentPreexistsError, @@ -34,6 +35,10 @@ export { isValidCell, assertValidCell, } from "./instructions.js"; +export { + InstructionInputSchema, + InstructionInputObjectSchema, +} from "./schema.js"; export type { Instruction, InstructionInput, diff --git a/src/instructions.ts b/src/instructions.ts index 5ccdfc5..4d58b82 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -268,6 +268,14 @@ export class EngineError extends Error { } } +/** + * The instruction failed schema validation at the {@link patch} boundary — a + * malformed field, a mismatched target shape, or the wrong payload carrier for + * its cell. Distinct from {@link InvalidCellError}, which is specifically an + * `operation × scope` combination outside the algebra. + */ +export class InvalidInstructionError extends EngineError {} + /** The requested operation×scope combination is not part of the algebra. */ export class InvalidCellError extends EngineError { constructor(public cell: Cell) { diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..875295e --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,247 @@ +/** + * The published Zod schema for a patch instruction. + * + * This is the single source of truth for the *shape and validity* of an + * instruction as a caller supplies it, from which downstream projects derive + * their own surfaces: obsidian-local-rest-api builds its MCP `vault_patch` tool + * input from {@link InstructionInputObjectSchema}'s field shape and its OpenAPI + * `PatchInstruction` component by running the same schema through + * `zod-to-json-schema`. Keeping it here means those three representations + * cannot drift. + * + * The schema is deliberately a *flat* object rather than a discriminated union: + * that is the only shape an MCP tool input accepts (a tool takes a flat + * `ZodRawShape`, never a top-level union), and it matches how both the REST and + * MCP layers already model an instruction. The cross-field rules that a + * discriminated union would encode in its structure — which carrier a cell + * requires, which `operation × scope` pairs are valid for a target — are instead + * enforced by {@link instructionAlgebra} in a `superRefine`. + * + * The hand-written {@link InstructionInput} union in `instructions.ts` remains + * the exported *type*; this schema is checked against it (see `schema.test.ts`) + * so the two stay in agreement. {@link patch} validates its input against + * {@link InstructionInputSchema} at the boundary, so a malformed instruction is + * rejected with a typed {@link InvalidInstructionError} before any handler runs. + */ + +import { z } from "zod"; + +import { + Operation, + Scope, + TargetType, + isValidCell, +} from "./instructions.js"; + +// --- Field pieces -------------------------------------------------------- + +const operationValues = [ + "replace", + "prepend", + "append", + "delete", +] as const satisfies readonly Operation[]; + +const scopeValues = [ + "content", + "marker", + "markerAndContent", + "parent", +] as const satisfies readonly Scope[]; + +const targetTypeValues = [ + "heading", + "block", + "frontmatter", +] as const satisfies readonly TargetType[]; + +/** A heading containment path, or `null`/`[]` for the document root. */ +const headingAddress = z + .union([z.array(z.string()), z.null()]) + .describe( + "A heading's containment path: the ancestor heading texts from the top level down (e.g. [\"Overview\",\"Details\"]), or null/[] for the document root." + ); + +/** Where a moved section lands among its new parent's children. */ +const place = z + .union([ + z.enum(["first", "last"]), + z.object({ before: headingAddress }).strict(), + z.object({ after: headingAddress }).strict(), + ]) + .describe( + "Position among the new parent's children: \"first\", \"last\", or { before } / { after } a sibling heading address." + ); + +const destination = z + .object({ + parent: headingAddress.describe( + "The section's new parent heading address, or null/[] for the document root." + ), + place, + }) + .strict() + .describe( + "For a heading move (operation `replace`, scope `parent`): where the section is re-parented. Provide exactly one of `content`, `value`, or `destination`." + ); + +// --- The flat object ----------------------------------------------------- + +/** + * The instruction as a flat object, before cross-field validation. Exposed so + * consumers can read its `.shape` (e.g. to build an MCP tool input, one Zod + * field per parameter). Use {@link InstructionInputSchema} to actually + * validate an instruction — it adds the {@link instructionAlgebra} refinement. + */ +export const InstructionInputObjectSchema = z + .object({ + targetType: z + .enum(targetTypeValues) + .describe("The kind of node to edit."), + target: z + .union([z.array(z.string()), z.string(), z.null()]) + .describe( + "The node to edit. For a heading: an array of heading texts from the top level down to the target (e.g. [\"Overview\",\"Details\"]), or null/[] for the document root. For a block: the bare block id, without the leading `^`. For a frontmatter field: the key." + ), + operation: z + .enum(operationValues) + .describe( + "What happens to the scoped span: replace it, insert before (`prepend`) or after (`append`), or `delete` it." + ), + scope: z + .enum(scopeValues) + .default("content") + .describe( + "Which part of the target the operation acts on (default `content`). `content`: the node body — for a heading, its whole subtree below the heading line. `marker`: the label only — a heading line, a block `^id`, or a frontmatter key (`replace` renames it). `markerAndContent`: the whole node/subtree (`prepend`/`append` insert a sibling). `parent`: a heading's place in the tree — only with operation `replace`, carrying a `destination` (a move)." + ), + content: z + .string() + .describe( + "String payload: a heading/block body or label, or a new frontmatter key name for a `marker` rename. Heading levels are relative to the edited span (a leading `#` becomes a direct child). Provide exactly one of `content`, `value`, or `destination`." + ) + .optional(), + value: z + .unknown() + .describe( + "Structured JSON payload for a frontmatter value — any JSON (string, number, boolean, array, object, null). For `prepend`/`append` this merges (list concat, dict merge, string concat). Provide exactly one of `content`, `value`, or `destination`." + ) + .optional(), + destination: destination.optional(), + ifMatch: z + .string() + .describe( + "Optimistic-concurrency token (the `version` from a prior document map). If set and the document has changed since, the patch fails with a precondition error without modifying the file." + ) + .optional(), + createTargetIfMissing: z + .boolean() + .default(false) + .describe( + "Create the target (heading path, block id, or frontmatter key) if it does not already exist." + ), + rejectIfContentPreexists: z + .boolean() + .default(false) + .describe( + "Fail a `prepend`/`append` when the string content already appears in the target span (makes those operations idempotent on retry)." + ), + }) + .describe( + "A single edit expressed as one operation applied to a scope of a target node. The payload rides in exactly one of `content`, `value`, or `destination`, chosen by what it is. Not every operation×scope×targetType combination is valid; invalid ones are rejected." + ); + +// --- The algebra (cross-field validity) ---------------------------------- + +/** Which payload carrier a valid cell requires (or `none` for a delete). */ +type Carrier = "content" | "value" | "destination" | "none"; + +/** + * The carrier a `(targetType, operation, scope)` cell expects, mirroring the + * {@link Instruction} union member for that cell. Assumes the cell is already + * known valid (see {@link isValidCell}). + */ +const expectedCarrier = ( + targetType: TargetType, + operation: Operation, + scope: Scope +): Carrier => { + if (operation === "delete") return "none"; + if (scope === "parent") return "destination"; // heading move + if (targetType === "frontmatter") { + return scope === "marker" ? "content" : "value"; // rename vs. value write + } + return "content"; // heading/block body, label, or whole-node write +}; + +const carriers = ["content", "value", "destination"] as const; + +/** + * Validate the cross-field rules the flat object cannot express on its own: the + * target shape must match its type, the `operation × scope` cell must be part of + * the algebra, and exactly the carrier that cell expects must be present. + */ +const instructionAlgebra = ( + input: z.infer, + ctx: z.RefinementCtx +): void => { + const { targetType, operation, scope, target } = input; + + // Target shape by type. + if (targetType === "heading") { + if (target !== null && !Array.isArray(target)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["target"], + message: + "a heading target must be an array of heading texts, or null for the document root", + }); + } + } else if (typeof target !== "string") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["target"], + message: `a ${targetType} target must be a string`, + }); + } + + // Cell validity. + if (!isValidCell(targetType, operation, scope)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["operation"], + message: `${operation} @ ${scope} is not a valid operation for a ${targetType} target`, + }); + return; // carrier expectations are undefined for an invalid cell + } + + // Carrier: exactly the one the cell expects, and no others. + const expected = expectedCarrier(targetType, operation, scope); + for (const carrier of carriers) { + const present = input[carrier] !== undefined; + if (carrier === expected && !present) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [carrier], + message: `${operation} @ ${scope} on a ${targetType} target requires \`${carrier}\``, + }); + } else if (carrier !== expected && present) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [carrier], + message: + expected === "none" + ? `a ${operation} carries no payload; remove \`${carrier}\`` + : `${operation} @ ${scope} on a ${targetType} target carries its payload in \`${expected}\`, not \`${carrier}\``, + }); + } + } +}; + +/** + * The full instruction schema: the flat object plus the {@link instructionAlgebra} + * cross-field refinement. {@link patch} parses input through this at the + * boundary. It is a `ZodEffects`, so read `.shape` from + * {@link InstructionInputObjectSchema} rather than from this. + */ +export const InstructionInputSchema = + InstructionInputObjectSchema.superRefine(instructionAlgebra); diff --git a/src/tests/schema.test.ts b/src/tests/schema.test.ts new file mode 100644 index 0000000..cb20adb --- /dev/null +++ b/src/tests/schema.test.ts @@ -0,0 +1,169 @@ +import { z } from "zod"; + +import { + InstructionInputSchema, + InstructionInputObjectSchema, +} from "../schema"; +import { patch } from "../engine"; +import { + InstructionInput, + InvalidInstructionError, +} from "../instructions"; + +// --- Type-level drift guard ---------------------------------------------- +// +// Every member of the hand-written InstructionInput union must be accepted by +// the flat schema's input type. If the union grows a field or tightens a type +// the schema does not mirror, this assignment stops compiling — which, under +// ts-jest, fails the suite. (The reverse does not hold and is not expected: the +// flat schema is looser at compile time and tightened at runtime by the +// superRefine, so a bare object without its carrier is a valid *type* but a +// runtime error.) +const _unionSatisfiesSchema: ( + i: InstructionInput +) => z.input = (i) => i; +void _unionSatisfiesSchema; + +// A representative instance of each union member, to exercise the schema at +// runtime the way callers actually use it. +const valid: { name: string; instruction: InstructionInput }[] = [ + { + name: "heading write @ content", + instruction: { targetType: "heading", target: ["A"], operation: "append", content: "x" }, + }, + { + name: "heading write @ marker", + instruction: { targetType: "heading", target: ["A"], operation: "replace", scope: "marker", content: "New" }, + }, + { + name: "heading move @ parent", + instruction: { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "parent", + destination: { parent: null, place: "last" }, + }, + }, + { + name: "heading delete", + instruction: { targetType: "heading", target: ["A"], operation: "delete", scope: "markerAndContent" }, + }, + { + name: "block write @ content", + instruction: { targetType: "block", target: "abc", operation: "append", content: "row" }, + }, + { + name: "block marker replace", + instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", content: "def" }, + }, + { + name: "frontmatter value write", + instruction: { targetType: "frontmatter", target: "title", operation: "replace", value: "T" }, + }, + { + name: "frontmatter value merge", + instruction: { targetType: "frontmatter", target: "tags", operation: "append", value: ["x"] }, + }, + { + name: "frontmatter rename", + instruction: { targetType: "frontmatter", target: "a", operation: "replace", scope: "marker", content: "b" }, + }, + { + name: "frontmatter delete", + instruction: { targetType: "frontmatter", target: "a", operation: "delete" }, + }, +]; + +describe("InstructionInputSchema", () => { + test.each(valid)("accepts a $name", ({ instruction }) => { + expect(InstructionInputSchema.safeParse(instruction).success).toBe(true); + }); + + test("defaults an omitted scope to content and the flags to false", () => { + const parsed = InstructionInputSchema.parse({ + targetType: "heading", + target: ["A"], + operation: "append", + content: "x", + }); + expect(parsed.scope).toBe("content"); + expect(parsed.createTargetIfMissing).toBe(false); + expect(parsed.rejectIfContentPreexists).toBe(false); + }); + + describe("rejects malformed instructions", () => { + const invalid: { name: string; instruction: unknown }[] = [ + { + name: "a heading target given as a string", + instruction: { targetType: "heading", target: "A", operation: "append", content: "x" }, + }, + { + name: "a block target given as an array", + instruction: { targetType: "block", target: ["a"], operation: "append", content: "x" }, + }, + { + name: "an invalid cell (parent on a block)", + instruction: { + targetType: "block", + target: "a", + operation: "replace", + scope: "parent", + destination: { parent: null, place: "last" }, + }, + }, + { + name: "an invalid cell (prepend on a frontmatter marker)", + instruction: { targetType: "frontmatter", target: "a", operation: "prepend", scope: "marker", content: "b" }, + }, + { + name: "a heading write carrying value instead of content", + instruction: { targetType: "heading", target: ["A"], operation: "replace", value: 1 }, + }, + { + name: "a frontmatter value write carrying content instead of value", + instruction: { targetType: "frontmatter", target: "a", operation: "replace", content: "x" }, + }, + { + name: "a heading write missing its content carrier", + instruction: { targetType: "heading", target: ["A"], operation: "replace" }, + }, + { + name: "a move missing its destination carrier", + instruction: { targetType: "heading", target: ["A"], operation: "replace", scope: "parent" }, + }, + { + name: "a delete carrying content", + instruction: { targetType: "heading", target: ["A"], operation: "delete", content: "x" }, + }, + ]; + + test.each(invalid)("rejects $name", ({ instruction }) => { + expect(InstructionInputSchema.safeParse(instruction).success).toBe(false); + }); + }); +}); + +describe("patch() boundary validation", () => { + test("throws InvalidInstructionError for a malformed instruction", () => { + expect(() => + // A heading write with no content carrier: valid cell, wrong shape. + patch("# A\n\nbody\n", { + targetType: "heading", + target: ["A"], + operation: "replace", + } as InstructionInput) + ).toThrow(InvalidInstructionError); + }); + + test("applies a well-formed instruction", () => { + const { document } = patch("# A\n\nbody\n", { + targetType: "frontmatter", + target: "title", + operation: "replace", + value: "Set", + createTargetIfMissing: true, + }); + expect(document).toContain("title: Set"); + }); +}); From 49a361b951494385a9cd2e1bfa8a0e52ce3c0d05 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Tue, 21 Jul 2026 10:33:54 -0500 Subject: [PATCH 28/73] List a repeated heading's descendants in the document map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public map keyed sibling headings by text and skipped a repeat outright, dropping its whole subtree. The resolver, though, addresses a heading by its entire containment path, so a uniquely-pathed descendant of a repeat resolves fine. Given `## Log / ### Monday` followed by `## Log / ### Tuesday`, `["Log", "Tuesday"]` patched correctly but never appeared in the map — a consumer whose only view of the document is that map could not discover a heading it was allowed to target. Build the tree by merging a repeated name into the existing subtree instead of skipping past it. Sections that share an entire path still collapse to a single address that resolves to the first in document order, which was always consistent; it is only the descendants below a repeat that were being hidden. This makes the tree exactly the set of addresses the resolver accepts, which is now pinned as an invariant: for a spread of documents, every advertised path resolves, and every heading in the model is advertised. Both halves matter, since over-reporting would hand out addresses that 404 while under-reporting hides reachable headings. Verified the new tests fail against the previous behavior. Co-Authored-By: Claude Opus 4.8 --- src/projection.ts | 33 +++++++------ src/tests/projection.test.ts | 89 ++++++++++++++++++++++++++++++------ 2 files changed, 95 insertions(+), 27 deletions(-) diff --git a/src/projection.ts b/src/projection.ts index 4425107..9e29d78 100644 --- a/src/projection.ts +++ b/src/projection.ts @@ -7,15 +7,17 @@ import { DocumentModel, SectionNode } from "./model.js"; * (an `h1` followed directly by an `h3`) does not appear as a hole — the engine * owns depth and a consumer never needs it. * - * Sibling headings are keyed by text, so a repeated sibling name cannot appear - * twice: the **first** occurrence in document order wins and later same-name - * siblings (with their subtrees) are omitted, matching the resolver, which - * resolves an address to the first match in document order. To reach a heading - * shadowed by an earlier duplicate, target the next-higher heading or the - * document as a whole. + * Sibling headings are keyed by text, so a repeated sibling name appears once. + * A repeat is not dropped, though — its children merge into the first + * occurrence's subtree — because the resolver addresses a heading by its whole + * containment path, not by its name. Two sections that share a path really are + * one address and resolve to the first in document order, while a + * uniquely-pathed descendant of a repeat is its own address and is listed. For + * `## Log / ### Monday` followed by `## Log / ### Tuesday`, the tree is + * `{ Log: { Monday: {}, Tuesday: {} } }`: both are separately addressable. * - * future: surface shadowed duplicate headings (e.g. under a reserved section of - * the map) so they are visible and addressable rather than silently omitted. + * The tree therefore enumerates exactly the addresses the resolver accepts — + * see {@link headingTreePaths}. */ export interface HeadingTree { [headingText: string]: HeadingTree; @@ -71,18 +73,23 @@ export const projectMap = (model: DocumentModel): PublicMap => { }; collectBlocks(model.root); - // Headings nest by containment, first-wins on a repeated sibling name. + // Headings nest by containment. A repeated sibling name reuses the existing + // subtree rather than starting a second one, so the repeat's descendants — + // which carry their own distinct containment paths — stay listed. This is + // what keeps the tree equal to the set of addresses the resolver accepts. const buildTree = (node: SectionNode, into: HeadingTree): void => { for (const child of node.children) { if (!child.heading) { continue; } const { text } = child.heading; - if (Object.prototype.hasOwnProperty.call(into, text)) { - continue; // shadowed duplicate; see {@link HeadingTree} + const existing = Object.prototype.hasOwnProperty.call(into, text) + ? into[text] + : undefined; + const subtree: HeadingTree = existing ?? {}; + if (!existing) { + into[text] = subtree; } - const subtree: HeadingTree = {}; - into[text] = subtree; buildTree(child, subtree); } }; diff --git a/src/tests/projection.test.ts b/src/tests/projection.test.ts index c44619d..546609a 100644 --- a/src/tests/projection.test.ts +++ b/src/tests/projection.test.ts @@ -1,5 +1,6 @@ -import { buildModel } from "../model"; -import { projectMap, headingTreePaths } from "../projection"; +import { buildModel, eachSection } from "../model"; +import { projectMap, headingTreePaths, headingPath } from "../projection"; +import { resolveHeading } from "../resolve"; describe("projectMap", () => { test("produces the first-pass public shape", () => { @@ -49,31 +50,91 @@ describe("projectMap", () => { expect(map.frontmatterFields).toEqual([]); }); - test("first-wins keeps the first duplicate's subtree, not the last", () => { + test("a repeated sibling name merges its children into one subtree", () => { const doc = "## Log\n\n### Monday\n\nm\n\n## Log\n\n### Tuesday\n\nt\n"; const map = projectMap(buildModel(doc)); - // The first "Log" (with Monday) wins; the second "Log" and Tuesday drop out. + // "Log" is one key, but Tuesday has its own containment path and so is its + // own address — dropping it would hide a heading the resolver can reach. + expect(map.headings).toEqual({ Log: { Monday: {}, Tuesday: {} } }); + }); + + test("sections that genuinely share a path collapse to one address", () => { + const doc = + "## Log\n\n### Monday\n\nfirst\n\n## Log\n\n### Monday\n\nsecond\n"; + const map = projectMap(buildModel(doc)); + // Both Mondays are ["Log", "Monday"]; that is one address, and it resolves + // to the first in document order. expect(map.headings).toEqual({ Log: { Monday: {} } }); }); - test("a block under a shadowed duplicate heading is still listed", () => { + test("a repeat's descendants merge even below a shared path", () => { + const doc = + "# A\n\n## X\n\nfirst\n\n# A\n\n## X\n\n### Z\n\nz\n"; + const map = projectMap(buildModel(doc)); + // ["A","X"] is shared, but ["A","X","Z"] is unique and stays addressable. + expect(map.headings).toEqual({ A: { X: { Z: {} } } }); + }); + + test("a block under a repeated heading is still listed", () => { const doc = "## Log\n\nfirst ^a\n\n## Log\n\nsecond ^b\n"; const map = projectMap(buildModel(doc)); - // The second "Log" is omitted from the tree, but its block stays addressable. + // Neither "Log" has child headings, so the tree has one leaf; blocks are + // addressed globally and both stay listed. expect(map.headings).toEqual({ Log: {} }); expect(map.blocks).toEqual(["a", "b"]); }); test("headingTreePaths enumerates every address in document order", () => { - const doc = "# A\n\n## B\n\nb\n\n### C\n\nc\n\n# D\n\nd\n"; - const paths = headingTreePaths(projectMap(buildModel(doc)).headings); - expect(paths).toEqual([ - ["A"], - ["A", "B"], - ["A", "B", "C"], - ["D"], - ]); + const paths = headingTreePaths( + projectMap(buildModel("# A\n\n## B\n\nb\n\n### C\n\nc\n\n# D\n\nd\n")).headings + ); + expect(paths).toEqual([["A"], ["A", "B"], ["A", "B", "C"], ["D"]]); + }); +}); + +// The map's contract: it advertises neither more nor less than the resolver +// accepts. Under-reporting hides reachable headings from a consumer whose only +// view of the document is this map; over-reporting hands out addresses that 404. +describe("map/resolver agreement — the tree is exactly the addressable set", () => { + const documents: Array<{ name: string; document: string }> = [ + { name: "plain nesting", document: "# A\n\n## B\n\nb\n\n### C\n\nc\n\n# D\n\nd\n" }, + { name: "skipped levels", document: "# A\n\nbody\n\n#### Deep\n\ndeep\n" }, + { + name: "repeated sibling with distinct children", + document: "## Log\n\n### Monday\n\nm\n\n## Log\n\n### Tuesday\n\nt\n", + }, + { + name: "repeated sibling with colliding children", + document: "## Log\n\n### Monday\n\nfirst\n\n## Log\n\n### Monday\n\nsecond\n", + }, + { + name: "repeat nested below a shared path", + document: "# A\n\n## X\n\nfirst\n\n# A\n\n## X\n\n### Z\n\nz\n", + }, + { name: "empty heading text", document: "# \n\nbody\n\n## Child\n\nc\n" }, + { name: "no headings at all", document: "just prose\n" }, + ]; + + test.each(documents)("$name", ({ document }) => { + const model = buildModel(document); + const advertised = headingTreePaths(projectMap(model).headings); + + // Every advertised address resolves. + for (const address of advertised) { + expect(resolveHeading(model, address)).not.toBeNull(); + } + + // ...and every heading in the document is advertised, so nothing reachable + // is hidden. Sections sharing a path are one address, hence the dedup. + const actual: string[][] = []; + eachSection(model.root, (node) => { + if (node.heading) { + actual.push(headingPath(node)); + } + }); + const key = (path: string[]): string => JSON.stringify(path); + expect(new Set(advertised.map(key))).toEqual(new Set(actual.map(key))); }); }); From 0fe12a9df23db04f631324b44e2e9494f9436f02 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Tue, 21 Jul 2026 21:36:37 -0500 Subject: [PATCH 29/73] De-level a heading's content on read, matching a content-scope write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readTarget returned a heading's content with the document's absolute heading levels, but a content-scope write rebases the value it receives relative to the target's own level (baseline = target level; see levels.ts). Reading a section and writing it straight back therefore re-levelled every nested heading inside it one level deeper on every round trip — the single most common editing pattern (fetch a section, edit, write it back) silently corrupted structure. De-level heading content by the target's own level before returning it, using relevelText (already used by move/dissolve for the same in-place releveling), so a read's output matches what a write expects as input. Root reads (baseline 0) are left as an exact slice rather than routed through relevelText's normalize/reapply round trip. Co-Authored-By: Claude Sonnet 5 --- src/read.ts | 16 +++++++++++- src/tests/read.test.ts | 55 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/read.ts b/src/read.ts index 0e25a07..ef3af68 100644 --- a/src/read.ts +++ b/src/read.ts @@ -3,11 +3,18 @@ * address (the same `(targetType, target)` pair a patch instruction carries) * resolves to a node, and the node's addressable value comes back. Headings and * blocks yield their content as a string; frontmatter yields the parsed value. + * + * A heading's content is de-levelled by the target's own level before it is + * returned, mirroring the baseline a `content`-scope write rebases *up* by (see + * `levels.ts`). Without this, a heading's content round-trips through a + * content-scope write at the wrong depth: reading section "# Overview"'s + * "## Details" child and writing it straight back would rebase it to "### Details". */ import { buildModel } from "./model.js"; import { resolveTarget, Addressed } from "./resolve.js"; import { headingContentRange, blockContentRange } from "./ranges.js"; +import { relevelText } from "./text.js"; import { TargetNotFoundError } from "./instructions.js"; /** The address of a read: the same addressing subset a patch instruction uses. */ @@ -36,7 +43,14 @@ export const readTarget = (document: string, target: ReadTarget): ReadResult => switch (resolved.kind) { case "heading": { const range = headingContentRange(resolved.section); - return { kind: "heading", content: document.slice(range.start, range.end) }; + const raw = document.slice(range.start, range.end); + const baseline = resolved.section.heading?.level ?? 0; + // Baseline 0 (the document root) needs no releveling; skip it so a root + // read stays a byte-identical slice rather than a normalize/reapply round + // trip through relevelText. + const content = + baseline === 0 ? raw : relevelText(raw, -baseline, model.lineEnding).text; + return { kind: "heading", content }; } case "block": { const range = blockContentRange(resolved.block); diff --git a/src/tests/read.test.ts b/src/tests/read.test.ts index da87a88..eff5a35 100644 --- a/src/tests/read.test.ts +++ b/src/tests/read.test.ts @@ -1,4 +1,5 @@ import { readTarget } from "../read"; +import { patch } from "../engine"; import { TargetNotFoundError } from "../instructions"; const DOC = @@ -21,7 +22,11 @@ describe("readTarget", () => { expect(result.kind).toBe("heading"); if (result.kind !== "frontmatter") { expect(result.content).toContain("The thesis."); - expect(result.content).toContain("## Details"); + // "## Details" is level 2 in the document, but content is de-levelled + // relative to "Overview" (level 1), matching what a content-scope write + // expects back — see the round-trip tests below. + expect(result.content).toContain("# Details"); + expect(result.content).not.toContain("## Details"); expect(result.content).toContain("Nested body."); expect(result.content).not.toContain("Elsewhere."); } @@ -57,6 +62,54 @@ describe("readTarget", () => { }); }); + test("a heading's content round-trips through a content-scope write unchanged", () => { + // readTarget's heading content must come back de-leveled the same way a + // content-scope write expects it (relative to the target's own level), or + // reading a section and writing it straight back re-levels every nested + // heading inside it. + const doc = + "# Overview\n\nIntro.\n\n## Details\n\nNested body.\n\n# Other\n\nElsewhere.\n"; + const result = readTarget(doc, { targetType: "heading", target: ["Overview"] }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "heading", + target: ["Overview"], + operation: "replace", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + + test("a nested heading's content round-trips through a content-scope write unchanged", () => { + const doc = + "# Overview\n\n## Details\n\nIntro.\n\n### Sub\n\nDeep body.\n\n# Other\n\nElsewhere.\n"; + const result = readTarget(doc, { + targetType: "heading", + target: ["Overview", "Details"], + }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "heading", + target: ["Overview", "Details"], + operation: "replace", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + + test("a document-root read still round-trips (baseline 0, no releveling needed)", () => { + const doc = "# One\n\nbody\n\n# Two\n\nbody two\n"; + const result = readTarget(doc, { targetType: "heading", target: null }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "heading", + target: null, + operation: "replace", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + test("an unresolvable target throws TargetNotFoundError", () => { expect(() => readTarget(DOC, { targetType: "heading", target: ["Nope"] }) From fcb01fab08c8ac1a124e8ea76a3156e3ccbe4709 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Tue, 21 Jul 2026 22:24:25 -0500 Subject: [PATCH 30/73] Add failing tests for table-row writes on a block target The 2.0 engine's block+content cell only ever accepted a literal markdown string, so InstructionInputSchema rejected value on a block target outright and there was no way to add/replace table rows structurally, despite obsidian-local-rest-api's docs already promising exactly that. These tests pin the intended behavior (append/prepend/ replace against the row data, keeping the header/separator; a column-count mismatch or a non-table target is a domain error) ahead of the implementation. Co-Authored-By: Claude Sonnet 5 --- src/tests/engine.test.ts | 99 ++++++++++++++++++++++++++++++++++++++++ src/tests/schema.test.ts | 26 +++++++++++ 2 files changed, 125 insertions(+) diff --git a/src/tests/engine.test.ts b/src/tests/engine.test.ts index db117a7..8920617 100644 --- a/src/tests/engine.test.ts +++ b/src/tests/engine.test.ts @@ -5,6 +5,7 @@ import { Instruction, } from "../instructions"; import { RootHasNoMarkerError } from "../ranges"; +import { NotATableError, TableColumnCountError } from "../engine/table"; // A small tree: A (h1) > B (h2), then C (h1), each with a one-line body and a // library-owned blank-line gap between siblings. @@ -265,6 +266,104 @@ describe("patch — block cells", () => { }); }); +describe("patch — block table-row cells", () => { + // Mirrors the design doc's worked example: an isolated `^id` line attaches + // to the whole preceding table (header, separator, and body rows). + const TABLE_DOC = + "| City | Population |\n" + + "| ------- | ---------- |\n" + + "| Seattle | 8 |\n" + + "^ref\n"; + + test("append @ content with a value inserts new rows after the existing body rows", () => { + const result = patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + value: [["Chicago", "16"]], + }); + expect(result.document).toBe( + "| City | Population |\n" + + "| ------- | ---------- |\n" + + "| Seattle | 8 |\n" + + "| Chicago | 16 |\n" + + "^ref\n" + ); + }); + + test("prepend @ content with a value inserts new rows right after the header/separator", () => { + const result = patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "prepend", + scope: "content", + value: [["Chicago", "16"]], + }); + expect(result.document).toBe( + "| City | Population |\n" + + "| ------- | ---------- |\n" + + "| Chicago | 16 |\n" + + "| Seattle | 8 |\n" + + "^ref\n" + ); + }); + + test("replace @ content with a value swaps all body rows, keeping the header/separator", () => { + const result = patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "replace", + scope: "content", + value: [["Chicago", "16"]], + }); + expect(result.document).toBe( + "| City | Population |\n" + + "| ------- | ---------- |\n" + + "| Chicago | 16 |\n" + + "^ref\n" + ); + }); + + test("a row with the wrong number of cells raises TableColumnCountError", () => { + expect(() => + patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + value: [["only-one-cell"]], + }) + ).toThrow(TableColumnCountError); + }); + + test("a value on a non-table block raises NotATableError", () => { + const result = "a paragraph ^ref\n"; + expect(() => + patch(result, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + value: [["x", "y"]], + }) + ).toThrow(NotATableError); + }); + + test("createTargetIfMissing is rejected for table-row writes", () => { + expect(() => + patch(TABLE_DOC, { + targetType: "block", + target: "nonexistent", + operation: "append", + scope: "content", + value: [["Chicago", "16"]], + createTargetIfMissing: true, + }) + ).toThrow(); + }); +}); + describe("patch — preconditions and resolution", () => { test("ifMatch matching the current version applies the patch", () => { // Compute the version via a no-op resolve by patching with the right token. diff --git a/src/tests/schema.test.ts b/src/tests/schema.test.ts index cb20adb..052e5bb 100644 --- a/src/tests/schema.test.ts +++ b/src/tests/schema.test.ts @@ -57,6 +57,10 @@ const valid: { name: string; instruction: InstructionInput }[] = [ name: "block marker replace", instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", content: "def" }, }, + { + name: "block table-row write", + instruction: { targetType: "block", target: "abc", operation: "append", value: [["a", "b"]] }, + }, { name: "frontmatter value write", instruction: { targetType: "frontmatter", target: "title", operation: "replace", value: "T" }, @@ -136,6 +140,28 @@ describe("InstructionInputSchema", () => { name: "a delete carrying content", instruction: { targetType: "heading", target: ["A"], operation: "delete", content: "x" }, }, + { + name: "a block content write carrying both content and value", + instruction: { + targetType: "block", + target: "abc", + operation: "append", + content: "x", + value: [["a", "b"]], + }, + }, + { + name: "a block content write with a value that isn't a 2-D array of strings", + instruction: { targetType: "block", target: "abc", operation: "append", value: ["a", "b"] }, + }, + { + name: "a block content write with a value row containing a non-string cell", + instruction: { targetType: "block", target: "abc", operation: "append", value: [["a", 1]] }, + }, + { + name: "a value on a block marker cell (only content/value on `content` scope)", + instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", value: [["a"]] }, + }, ]; test.each(invalid)("rejects $name", ({ instruction }) => { From 2985c041d77f5cc69995e44ceca68e2f1fcd3a20 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Tue, 21 Jul 2026 22:29:36 -0500 Subject: [PATCH 31/73] Add table-row support to the 2.0 engine's block content cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block+content cell (already valid for replace/prepend/append) now accepts either carrier: `content` (literal markdown, unchanged) or `value` (a string[][] of table rows), the same way frontmatter already distinguishes `content` from `value` within one target type. This is the capability obsidian-local-rest-api's docs described but the 2.0 engine never actually implemented (a prior pass only fixed a stale OpenAPI example, not the underlying gap). - schema.ts: the carrier check is now set-based (most cells still expect exactly one carrier) so this one cell can accept either, plus a structural (document-independent) check that a `value` carrier is a 2-D array of strings. - engine/table.ts: parses the table's header/separator out of block.content and edits only the body rows. block.content sometimes carries its own trailing line ending and sometimes doesn't, depending on whether the block's `^id` is inline in the same marked token or isolated on its own line — parseTable() detects and preserves whichever convention the source has, rather than assuming one. - Column-count mismatches and non-table targets are runtime EngineError subclasses (NotATableError, TableColumnCountError), since both need the resolved block and can't be checked at the schema boundary. - createTargetIfMissing is explicitly rejected for table-row writes (minting a new table from row data alone is out of scope). Co-Authored-By: Claude Sonnet 5 --- src/engine.ts | 10 +++- src/engine/create.ts | 6 +++ src/engine/table.ts | 80 +++++++++++++++++++++++++++++ src/index.ts | 3 ++ src/instructions.ts | 25 ++++++++- src/schema.ts | 92 ++++++++++++++++++++++++++-------- src/tests/instructions.test.ts | 48 +++++++++++++++++- 7 files changed, 238 insertions(+), 26 deletions(-) create mode 100644 src/engine/table.ts diff --git a/src/engine.ts b/src/engine.ts index 0aefcbf..3c24c88 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -26,6 +26,7 @@ import { toLineEnding, sectionFragment, splice } from "./text.js"; import { structuralHeading, deleteBlock } from "./engine/structural.js"; import { patchFrontmatter } from "./engine/frontmatter.js"; import { createHeading, createBlock } from "./engine/create.js"; +import { patchTableRows } from "./engine/table.js"; import { Instruction, InstructionInput, @@ -39,6 +40,7 @@ import { InvalidInstructionError, assertValidCell, withDefaultScope, + isBlockTableRowInstruction, } from "./instructions.js"; import { InstructionInputSchema } from "./schema.js"; import { ResolvedTarget } from "./resolve.js"; @@ -188,8 +190,12 @@ const patchBlock = ( if (instruction.operation === "delete") { return deleteBlock(document, model, instruction, block); } - // Excluding delete narrows to BlockWrite | BlockMarkerReplace; both carry a - // string `content`. Block content and ids are literal, never rebased. + if (isBlockTableRowInstruction(instruction)) { + return patchTableRows(document, model, instruction, block); + } + // Excluding delete and table rows narrows to BlockWrite | BlockMarkerReplace; + // both carry a string `content`. Block content and ids are literal, never + // rebased. const { operation, scope } = instruction; const value = toLineEnding(instruction.content, model.lineEnding); diff --git a/src/engine/create.ts b/src/engine/create.ts index 966e09d..9264b0e 100644 --- a/src/engine/create.ts +++ b/src/engine/create.ts @@ -21,6 +21,7 @@ import { Warning, EngineError, TargetNotFoundError, + isBlockTableRowInstruction, } from "../instructions.js"; const MAX_HEADING_LEVEL = 6; @@ -99,6 +100,11 @@ export const createBlock = ( "createTargetIfMissing for blocks supports content-scope writes only" ); } + if (isBlockTableRowInstruction(instruction)) { + throw new EngineError( + "createTargetIfMissing for blocks does not support table-row writes; create the table first, then append/prepend/replace rows" + ); + } const value = toLineEnding(instruction.content, model.lineEnding); const blockText = `${value} ^${instruction.target}`; const le = model.lineEnding; diff --git a/src/engine/table.ts b/src/engine/table.ts new file mode 100644 index 0000000..1a10719 --- /dev/null +++ b/src/engine/table.ts @@ -0,0 +1,80 @@ +/** + * Table-row writes: `replace`/`prepend`/`append` @ `content` on a `block` + * target carrying structured `value` rather than literal `content` text (see + * {@link isBlockTableRowInstruction}). Unlike a literal-text block write, which + * splices `block.content` verbatim, a row write parses the table's header and + * separator lines out of that span and edits only the body rows beneath them. + */ + +import { DocumentModel, BlockNode } from "../model.js"; +import { + BlockTableRowInstruction, + PatchResult, + EngineError, +} from "../instructions.js"; +import { splice } from "../text.js"; + +/** The instruction's `value` targets a block whose `kind` isn't `"table"`. */ +export class NotATableError extends EngineError {} + +/** A row's cell count doesn't match the table's column count. */ +export class TableColumnCountError extends EngineError {} + +interface ParsedTable { + header: string; + separator: string; + rows: string[]; + /** Whether `block.content` included its own trailing line ending: true when + * the `^id` sits inline in the same token (content runs up to the marker), + * false when it's isolated on its own line (content is EOL-stripped). */ + trailingEol: boolean; +} + +const EOL_AT_END = /\r\n$|\r$|\n$/; + +const parseTable = (text: string): ParsedTable => { + const trailingEol = EOL_AT_END.test(text); + const lines = (trailingEol ? text.replace(EOL_AT_END, "") : text).split(/\r\n|\r|\n/); + return { header: lines[0] ?? "", separator: lines[1] ?? "", rows: lines.slice(2), trailingEol }; +}; + +const formatRow = (row: string[]): string => "| " + row.join(" | ") + " |"; + +export const patchTableRows = ( + document: string, + model: DocumentModel, + instruction: BlockTableRowInstruction, + block: BlockNode +): PatchResult => { + if (block.kind !== "table" || !block.columns) { + throw new NotATableError( + `block "${block.id}" is not a table; row writes require a table block` + ); + } + + const columnCount = block.columns.length; + for (const row of instruction.value) { + if (row.length !== columnCount) { + throw new TableColumnCountError( + `row ${JSON.stringify(row)} has ${row.length} cell(s); table "${block.id}" has ${columnCount} column(s)` + ); + } + } + + const { header, separator, rows: existingRows, trailingEol } = parseTable( + document.slice(block.content.start, block.content.end) + ); + const newRows = instruction.value.map(formatRow); + + const bodyRows = + instruction.operation === "replace" + ? newRows + : instruction.operation === "prepend" + ? [...newRows, ...existingRows] + : [...existingRows, ...newRows]; + + const text = + [header, separator, ...bodyRows].join(model.lineEnding) + + (trailingEol ? model.lineEnding : ""); + return splice(document, [{ range: block.content, text }], []); +}; diff --git a/src/index.ts b/src/index.ts index 53fcd75..5016648 100755 --- a/src/index.ts +++ b/src/index.ts @@ -34,11 +34,13 @@ export { MergeError, isValidCell, assertValidCell, + isBlockTableRowInstruction, } from "./instructions.js"; export { InstructionInputSchema, InstructionInputObjectSchema, } from "./schema.js"; +export { NotATableError, TableColumnCountError } from "./engine/table.js"; export type { Instruction, InstructionInput, @@ -50,6 +52,7 @@ export type { BlockWriteInstruction, BlockMarkerReplaceInstruction, BlockDeleteInstruction, + BlockTableRowInstruction, FrontmatterInstruction, FrontmatterValueInstruction, FrontmatterRenameInstruction, diff --git a/src/instructions.ts b/src/instructions.ts index 4d58b82..e2ee370 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -134,10 +134,33 @@ export interface BlockDeleteInstruction extends BlockTargeted { operation: "delete"; scope: "content" | "marker" | "markerAndContent"; } +/** + * `replace`/`prepend`/`append` on a table block's rows: `value` is a 2-D array + * of cell text, one entry per row. Shares the `block` + `content` cell with + * {@link BlockWriteInstruction} — the carrier chosen (`content` vs `value`) + * decides whether the write is literal text or structured table rows. + * `replace` swaps the body rows (keeping the header/separator); `prepend`/ + * `append` insert before/after the existing body rows. + */ +export interface BlockTableRowInstruction extends BlockTargeted { + operation: "replace" | "prepend" | "append"; + scope: "content"; + value: string[][]; +} export type BlockInstruction = | BlockWriteInstruction | BlockMarkerReplaceInstruction - | BlockDeleteInstruction; + | BlockDeleteInstruction + | BlockTableRowInstruction; + +/** True when `instruction` is a table-row write: a `block` target's `content` + * cell carrying structured `value` rather than literal `content` text. */ +export const isBlockTableRowInstruction = ( + instruction: BlockInstruction +): instruction is BlockTableRowInstruction => + instruction.scope === "content" && + instruction.operation !== "delete" && + "value" in instruction; // --- Frontmatter instructions -------------------------------------------- diff --git a/src/schema.ts b/src/schema.ts index 875295e..02abcb3 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -123,7 +123,7 @@ export const InstructionInputObjectSchema = z value: z .unknown() .describe( - "Structured JSON payload for a frontmatter value — any JSON (string, number, boolean, array, object, null). For `prepend`/`append` this merges (list concat, dict merge, string concat). Provide exactly one of `content`, `value`, or `destination`." + "Structured JSON payload: a frontmatter value (any JSON — string, number, boolean, array, object, null; for `prepend`/`append` this merges: list concat, dict merge, string concat), or table rows on a `block` target's `content` cell (a 2-D array of strings, one row per entry — `replace` swaps the body rows, `prepend`/`append` insert before/after the existing ones; each row's length must match the table's column count). Provide exactly one of `content`, `value`, or `destination`." ) .optional(), destination: destination.optional(), @@ -156,29 +156,44 @@ export const InstructionInputObjectSchema = z type Carrier = "content" | "value" | "destination" | "none"; /** - * The carrier a `(targetType, operation, scope)` cell expects, mirroring the - * {@link Instruction} union member for that cell. Assumes the cell is already - * known valid (see {@link isValidCell}). + * The carrier(s) a `(targetType, operation, scope)` cell accepts, mirroring the + * {@link Instruction} union member(s) for that cell. Every cell expects exactly + * one carrier except `block`'s `content` cell, which accepts either `content` + * (literal text) or `value` (structured table rows) — the same way `frontmatter` + * already distinguishes `content` (key rename) from `value` (value write), just + * within one scope instead of across two. Assumes the cell is already known + * valid (see {@link isValidCell}). */ -const expectedCarrier = ( +const expectedCarriers = ( targetType: TargetType, operation: Operation, scope: Scope -): Carrier => { - if (operation === "delete") return "none"; - if (scope === "parent") return "destination"; // heading move +): readonly Carrier[] => { + if (operation === "delete") return ["none"]; + if (scope === "parent") return ["destination"]; // heading move if (targetType === "frontmatter") { - return scope === "marker" ? "content" : "value"; // rename vs. value write + return scope === "marker" ? ["content"] : ["value"]; // rename vs. value write } - return "content"; // heading/block body, label, or whole-node write + if (targetType === "block" && scope === "content") { + return ["content", "value"]; // literal text, or structured table rows + } + return ["content"]; // heading/block label or whole-node write }; const carriers = ["content", "value", "destination"] as const; +/** A 2-D array of strings: table rows for a `block` `content`-cell `value` write. */ +const isTableRowValue = (value: unknown): value is string[][] => + Array.isArray(value) && + value.every( + (row) => Array.isArray(row) && row.every((cell) => typeof cell === "string") + ); + /** * Validate the cross-field rules the flat object cannot express on its own: the * target shape must match its type, the `operation × scope` cell must be part of - * the algebra, and exactly the carrier that cell expects must be present. + * the algebra, and exactly one of the carrier(s) the cell expects must be + * present — with the right shape, for cells whose carrier is structured. */ const instructionAlgebra = ( input: z.infer, @@ -214,26 +229,59 @@ const instructionAlgebra = ( return; // carrier expectations are undefined for an invalid cell } - // Carrier: exactly the one the cell expects, and no others. - const expected = expectedCarrier(targetType, operation, scope); - for (const carrier of carriers) { - const present = input[carrier] !== undefined; - if (carrier === expected && !present) { + // Carrier: exactly one of the ones the cell expects, and no others. + const expected = expectedCarriers(targetType, operation, scope); + const present = carriers.filter((carrier) => input[carrier] !== undefined); + + if (expected[0] === "none") { + for (const carrier of present) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [carrier], - message: `${operation} @ ${scope} on a ${targetType} target requires \`${carrier}\``, + message: `a ${operation} carries no payload; remove \`${carrier}\``, }); - } else if (carrier !== expected && present) { + } + return; + } + + if (present.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [expected[0]], + message: `${operation} @ ${scope} on a ${targetType} target requires ${ + expected.length === 1 ? `\`${expected[0]}\`` : expected.map((c) => `\`${c}\``).join(" or ") + }`, + }); + } else if (present.length > 1) { + for (const carrier of present) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [carrier], - message: - expected === "none" - ? `a ${operation} carries no payload; remove \`${carrier}\`` - : `${operation} @ ${scope} on a ${targetType} target carries its payload in \`${expected}\`, not \`${carrier}\``, + message: `${operation} @ ${scope} on a ${targetType} target carries its payload in exactly one of ${expected + .map((c) => `\`${c}\``) + .join(" or ")}, not both`, }); } + } else if (!expected.includes(present[0])) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [present[0]], + message: `${operation} @ ${scope} on a ${targetType} target carries its payload in ${ + expected.length === 1 ? `\`${expected[0]}\`` : expected.map((c) => `\`${c}\``).join(" or ") + }, not \`${present[0]}\``, + }); + } else if ( + targetType === "block" && + scope === "content" && + present[0] === "value" && + !isTableRowValue(input.value) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["value"], + message: + "value for a block content write must be a 2-D array of strings — one row per entry, one cell per column", + }); } }; diff --git a/src/tests/instructions.test.ts b/src/tests/instructions.test.ts index db5d886..b734d69 100644 --- a/src/tests/instructions.test.ts +++ b/src/tests/instructions.test.ts @@ -8,6 +8,7 @@ import { assertValidCell, InvalidCellError, withDefaultScope, + isBlockTableRowInstruction, } from "../instructions"; const OPERATIONS: Operation[] = ["replace", "prepend", "append", "delete"]; @@ -138,6 +139,13 @@ describe("Instruction typing (compile-time)", () => { target: "thesis", content: "revised-thesis", }, + { + targetType: "block", + operation: "append", + scope: "content", + target: "population-table", + value: [["Chicago, IL", "16"]], + }, { targetType: "frontmatter", operation: "append", @@ -153,7 +161,45 @@ describe("Instruction typing (compile-time)", () => { content: "state", }, ]; - expect(examples).toHaveLength(6); + expect(examples).toHaveLength(7); + }); +}); + +describe("isBlockTableRowInstruction", () => { + test("true for a block content cell carrying value", () => { + expect( + isBlockTableRowInstruction({ + targetType: "block", + target: "abc", + operation: "append", + scope: "content", + value: [["a", "b"]], + }) + ).toBe(true); + }); + + test("false for a block content cell carrying literal content", () => { + expect( + isBlockTableRowInstruction({ + targetType: "block", + target: "abc", + operation: "append", + scope: "content", + content: "text", + }) + ).toBe(false); + }); + + test("false for a block marker cell", () => { + expect( + isBlockTableRowInstruction({ + targetType: "block", + target: "abc", + operation: "replace", + scope: "marker", + content: "newid", + }) + ).toBe(false); }); }); From cabec0a429e397a91092e7e53c9af9e0669b3356 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Tue, 21 Jul 2026 22:55:23 -0500 Subject: [PATCH 32/73] Add failing tests for rejectIfContentPreexists on heading content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rejectIfContentPreexists compares the caller's content against the target's current (absolute-level) span, but a heading write's content carries levels relative to the edited span's baseline. For a heading at content-scope start, "#".repeat(n) happens to be a suffix of "#".repeat(n+baseline), so a single leading heading matches by accident — but content with a heading anywhere else (or a second heading) breaks that coincidence and the guard silently passes when the content is, once rebased, already present. These tests pin the correct behavior for content-scope and markerAndContent-scope (parent-level baseline) writes ahead of the fix. Co-Authored-By: Claude Sonnet 5 --- src/tests/engine.test.ts | 54 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/tests/engine.test.ts b/src/tests/engine.test.ts index 8920617..925908a 100644 --- a/src/tests/engine.test.ts +++ b/src/tests/engine.test.ts @@ -2,6 +2,7 @@ import { patch } from "../engine"; import { PreconditionFailedError, TargetNotFoundError, + ContentPreexistsError, Instruction, } from "../instructions"; import { RootHasNoMarkerError } from "../ranges"; @@ -403,6 +404,59 @@ describe("patch — preconditions and resolution", () => { }); }); +describe("patch — rejectIfContentPreexists on heading-bearing content", () => { + // A's content already contains a rebased (absolute-level) heading: "## + // Already Here" is what "# Already Here" becomes once rebased to A's + // baseline (1). A naive comparison of the *raw* (relative-level) instruction + // content against this *absolute*-level span never matches once the heading + // isn't the very first thing in the content — the leading "#" run no longer + // lines up as a lucky substring — so the guard silently passes when it + // shouldn't. + const doc = "# A\nintro\n## Already Here\nbody\n\n# C\nc-body\n"; + + test("detects relative-level content that already exists once rebased to the target's level", () => { + expect(() => + patch(doc, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "intro\n# Already Here\nbody\n", + rejectIfContentPreexists: true, + }) + ).toThrow(ContentPreexistsError); + }); + + test("does not reject genuinely new heading-bearing content", () => { + const result = patch(doc, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "intro\n# Not Here Yet\nbody\n", + rejectIfContentPreexists: true, + }); + expect(result.document).toContain("## Not Here Yet"); + }); + + test("detects relative-level content already present under markerAndContent (parent-level baseline)", () => { + // markerAndContent rebases to the *parent's* level, not the target's own — + // here B's parent A is level 1, so a relative "# B" (1 hash) is what B's + // own absolute "## B" (2 hashes) looks like at that baseline. + const nestedDoc = "# A\n## B\nintro\n### Already Here\nbody\n\n# C\nc-body\n"; + expect(() => + patch(nestedDoc, { + targetType: "heading", + target: ["A", "B"], + operation: "prepend", + scope: "markerAndContent", + content: "# B\nintro\n## Already Here\nbody\n", + rejectIfContentPreexists: true, + }) + ).toThrow(ContentPreexistsError); + }); +}); + describe("patch — scope defaults to content", () => { test("a heading write with no scope edits the section body", () => { const result = patch(DOC, { From 5167de528b3a533cf3736e3fd77c0740b11eb745 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Tue, 21 Jul 2026 22:56:17 -0500 Subject: [PATCH 33/73] Rebase heading levels before the rejectIfContentPreexists comparison The guard compared a heading write's content against the target's current span without accounting for relative-vs-absolute heading levels: content-scope and markerAndContent-scope values carry `#` counts relative to their baseline (see levels.ts), but the span read from the document is always absolute. A single leading heading matched by coincidence (repeated "#" characters make the shorter relative prefix a literal suffix of the longer absolute one), which masked the bug until content had a heading anywhere past the very start. Fix: rebase the probe the same way the write path already rebases content before splicing it in (sectionFragment/rebaseHeadings), using the same per-scope baseline (target's own level for content scope, parent's level for markerAndContent). Block and frontmatter targets, and marker-scope label text, carry no heading semantics and are compared as-is, unchanged. Co-Authored-By: Claude Sonnet 5 --- src/engine.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/engine.ts b/src/engine.ts index 3c24c88..b739fb4 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -23,6 +23,7 @@ import { blockFullRange, } from "./ranges.js"; import { toLineEnding, sectionFragment, splice } from "./text.js"; +import { rebaseHeadings } from "./levels.js"; import { structuralHeading, deleteBlock } from "./engine/structural.js"; import { patchFrontmatter } from "./engine/frontmatter.js"; import { createHeading, createBlock } from "./engine/create.js"; @@ -89,6 +90,34 @@ const scopeSpanText = ( return null; }; +/** + * The text to search for within {@link scopeSpanText} when checking + * `rejectIfContentPreexists`. The span is always absolute-level document + * text, but a heading write's `content` carries levels *relative* to the + * edited span (see `levels.ts`) — so a naive substring check against the raw + * caller value never matches content containing `#` headings, silently + * defeating the idempotency guard. Rebasing the value the same way the write + * path would (before splicing it in) keeps the comparison apples-to-apples. + * `marker` scope and non-heading targets carry no heading semantics, so the + * value is compared as-is. + */ +const preexistsProbe = ( + content: string, + resolved: ResolvedTarget, + scope: string +): string => { + if (resolved.kind !== "heading") { + return content; + } + if (scope === "content") { + return rebaseHeadings(content, resolved.section.heading?.level ?? 0).text; + } + if (scope === "markerAndContent") { + return rebaseHeadings(content, parentLevel(resolved.section)).text; + } + return content; +}; + // --- Heading handlers ---------------------------------------------------- const patchHeading = ( @@ -302,7 +331,8 @@ export const patch = ( instruction.content.trim().length > 0 ) { const span = scopeSpanText(document, resolved, instruction.scope); - if (span !== null && span.includes(instruction.content.trim())) { + const probe = preexistsProbe(instruction.content, resolved, instruction.scope); + if (span !== null && span.includes(probe.trim())) { throw new ContentPreexistsError( `the target already contains the content to ${instruction.operation}` ); From 37366bf4796b1053593009717974298f7cf4521b Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 06:07:31 -0500 Subject: [PATCH 34/73] Relocate FrontmatterParseError into the 2.0 engine's error hierarchy FrontmatterParseError previously lived in map.ts as a plain Error, scoped to the deprecated 1.x-shape getDocumentMap function. Moving it into instructions.ts as an EngineError subclass lets the 2.0 model layer reuse the same class (rather than defining a second, differently-typed error with the same name) when it starts validating frontmatter YAML on read. map.ts re-exports it unchanged, so existing 1.x callers are unaffected. Also adds FrontmatterKeyCollisionError, needed by an upcoming fix to patchFrontmatter's silent key-collision behavior. Co-Authored-By: Claude Sonnet 5 --- src/index.ts | 7 +++---- src/instructions.ts | 10 ++++++++++ src/map.ts | 9 ++------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5016648..febfd93 100755 --- a/src/index.ts +++ b/src/index.ts @@ -9,10 +9,7 @@ export { TablePartsNotFound, applyPatch, } from "./patch.js"; -export { - getDocumentMap, - FrontmatterParseError, -} from "./map.js"; +export { getDocumentMap } from "./map.js"; export * from "./types.js"; @@ -32,6 +29,8 @@ export { PreconditionFailedError, ContentPreexistsError, MergeError, + FrontmatterParseError, + FrontmatterKeyCollisionError, isValidCell, assertValidCell, isBlockTableRowInstruction, diff --git a/src/instructions.ts b/src/instructions.ts index e2ee370..86a034a 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -320,6 +320,16 @@ export class ContentPreexistsError extends EngineError {} /** A frontmatter merge or type mismatch made the operation impossible. */ export class MergeError extends EngineError {} +/** The frontmatter block could not be parsed as YAML. */ +export class FrontmatterParseError extends EngineError {} + +/** + * A frontmatter `marker`-scope rename, or a `markerAndContent` insert, would + * introduce a second entry with the same key — silently dropping one of the + * two values on serialization. + */ +export class FrontmatterKeyCollisionError extends EngineError {} + /** Throw {@link InvalidCellError} unless the cell is part of the algebra. */ export const assertValidCell = (cell: Cell): void => { if (!isValidCell(cell.targetType, cell.operation, cell.scope)) { diff --git a/src/map.ts b/src/map.ts index 3410dd4..1af2578 100644 --- a/src/map.ts +++ b/src/map.ts @@ -12,14 +12,9 @@ import { CAN_INCLUDE_BLOCK_REFERENCE, TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE, } from "./constants.js"; +import { FrontmatterParseError } from "./instructions.js"; -export class FrontmatterParseError extends Error { - constructor(message: string) { - super(message); - this.name = "FrontmatterParseError"; - Object.setPrototypeOf(this, new.target.prototype); - } -} +export { FrontmatterParseError }; function getHeadingPositions( document: string, From 39f6b7e37fc8e94ea527d8af6d6dc828ba232f65 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 06:08:21 -0500 Subject: [PATCH 35/73] Add failing tests for malformed frontmatter and key-collision handling buildModel currently lets a raw YAMLParseError escape uncaught when frontmatter YAML is malformed (e.g. an unescaped colon in a scalar value), rather than the typed FrontmatterParseError the 1.x engine raises for the same input. This breaks buildModel itself and everything built on it: patch, readTarget, and (via projectMap) the document map. patchFrontmatter also silently drops data on a key collision: renaming a key onto one that already exists, or inserting a markerAndContent entry whose key is already present, produces two pairs with the same key, and yaml.stringify's re-serialization silently keeps only the last one written. Co-Authored-By: Claude Sonnet 5 --- src/tests/frontmatter.test.ts | 80 ++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/tests/frontmatter.test.ts b/src/tests/frontmatter.test.ts index d2298c4..1811dc7 100644 --- a/src/tests/frontmatter.test.ts +++ b/src/tests/frontmatter.test.ts @@ -1,5 +1,18 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + import { patch } from "../engine"; -import { MergeError } from "../instructions"; +import { buildModel } from "../model"; +import { readTarget } from "../read"; +import { + MergeError, + FrontmatterParseError, + FrontmatterKeyCollisionError, +} from "../instructions"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); const FM = "---\ntitle: Hello\ntags:\n - a\n - b\n---\nbody text\n"; @@ -156,3 +169,68 @@ describe("patch — frontmatter markerAndContent cells", () => { ); }); }); + +describe("buildModel — malformed frontmatter", () => { + const colonInFrontmatter = fs.readFileSync( + path.join(__dirname, "sample.frontmatter.colon-in-value.md"), + "utf-8" + ); + + test("buildModel throws FrontmatterParseError rather than a raw YAML error", () => { + expect(() => buildModel(colonInFrontmatter)).toThrow(FrontmatterParseError); + }); + + test("patch throws FrontmatterParseError, even for a non-frontmatter target", () => { + expect(() => + patch(colonInFrontmatter, { + targetType: "block", + target: "block-1", + operation: "replace", + content: "New content.", + }) + ).toThrow(FrontmatterParseError); + }); + + test("readTarget throws FrontmatterParseError", () => { + expect(() => + readTarget(colonInFrontmatter, { targetType: "block", target: "block-1" }) + ).toThrow(FrontmatterParseError); + }); +}); + +describe("patch — frontmatter key collisions", () => { + test("renaming a key onto an existing key raises FrontmatterKeyCollisionError", () => { + expect(() => + patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "replace", + scope: "marker", + content: "tags", + }) + ).toThrow(FrontmatterKeyCollisionError); + }); + + test("inserting an entry whose key already exists raises FrontmatterKeyCollisionError", () => { + expect(() => + patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "append", + scope: "markerAndContent", + value: { tags: ["c"] }, + }) + ).toThrow(FrontmatterKeyCollisionError); + }); + + test("renaming a key to its own name is not a collision", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "replace", + scope: "marker", + content: "title", + }); + expect(result.document).toBe(FM); + }); +}); From 92932e9ddc7332361e82d761dd7f78168bf59dc2 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 06:09:13 -0500 Subject: [PATCH 36/73] Reject malformed frontmatter YAML and colliding frontmatter keys buildFrontmatter now catches parseYaml's throw and raises the typed FrontmatterParseError instead of letting a raw YAMLParseError escape uncaught, matching the 1.x engine's behavior for the same input. Since every 2.0 entry point (patch, readTarget, projectMap) goes through buildModel, this fixes the document map, targeted reads, and writes alike for any document whose frontmatter fails to parse. patchFrontmatter now also rejects a marker-scope rename onto an existing key, and a markerAndContent insert whose key already exists, with FrontmatterKeyCollisionError rather than silently letting yaml.stringify's object-keyed re-serialization drop one of the two colliding entries. Co-Authored-By: Claude Sonnet 5 --- src/engine/frontmatter.ts | 18 +++++++++++++++++- src/model.ts | 10 +++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/engine/frontmatter.ts b/src/engine/frontmatter.ts index 38bb48b..2f90c5b 100644 --- a/src/engine/frontmatter.ts +++ b/src/engine/frontmatter.ts @@ -24,6 +24,7 @@ import { PatchResult, MergeError, TargetNotFoundError, + FrontmatterKeyCollisionError, } from "../instructions.js"; type Pair = [string, unknown]; @@ -92,7 +93,13 @@ export const patchFrontmatter = ( if (instruction.scope === "marker") { // Rename the key, keeping its value and position (replace-only per matrix). - pairs[index] = [instruction.content, pairs[index][1]]; + const newKey = instruction.content; + if (newKey !== key && pairs.some(([existing]) => existing === newKey)) { + throw new FrontmatterKeyCollisionError( + `cannot rename frontmatter key "${key}" to "${newKey}": a key with that name already exists` + ); + } + pairs[index] = [newKey, pairs[index][1]]; } else if (instruction.operation === "delete") { if (instruction.scope === "content") { pairs[index] = [key, null]; // clear the value, keep the key @@ -128,6 +135,15 @@ export const patchFrontmatter = ( "inserting frontmatter entries requires a dictionary of key/value pairs" ); } + const incomingKeys = Object.keys(content); + const collision = incomingKeys.find((k) => + pairs.some(([existing]) => existing === k) + ); + if (collision) { + throw new FrontmatterKeyCollisionError( + `cannot insert frontmatter key "${collision}": a key with that name already exists` + ); + } const at = instruction.operation === "prepend" ? index : index + 1; pairs.splice(at, 0, ...(Object.entries(content) as Pair[])); } diff --git a/src/model.ts b/src/model.ts index de7bcb8..e631b29 100644 --- a/src/model.ts +++ b/src/model.ts @@ -7,6 +7,7 @@ import { CAN_INCLUDE_BLOCK_REFERENCE, TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE, } from "./constants.js"; +import { FrontmatterParseError } from "./instructions.js"; /** * A single section of a document: a heading plus the body that belongs @@ -400,7 +401,14 @@ const buildFrontmatter = ( return { entries: [], block: null }; } const block: DocumentRange = { start: 0, end: contentOffset }; - const parsed = parseYaml(frontmatterText.trim()); + let parsed: unknown; + try { + parsed = parseYaml(frontmatterText.trim()); + } catch (e) { + throw new FrontmatterParseError( + `Could not parse document frontmatter: ${(e as Error).message}` + ); + } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return { entries: [], block }; } From 24b46870dbd6ca95963b22ebc0ae6e6922346d42 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 06:47:16 -0500 Subject: [PATCH 37/73] Rename SectionNode.content to SectionNode.body SectionNode.content (a section's direct body, stopping at its first child heading) and the content *scope* exposed by the engine's content-scope operations (headingContentRange, which spans a section's whole subtree) share the word "content" but mean different things -- a mix-up that led directly to data loss when reasoning about a heading's content scope from the field name alone. Renaming the field to `body` removes the collision; no behavior changes, since every read of the field already went through headingContentRange or subtreeContentRange rather than being read directly by API-facing code. Co-Authored-By: Claude Sonnet 5 --- src/engine/structural.ts | 6 +++--- src/model.ts | 18 +++++++++--------- src/ranges.ts | 6 +++--- src/tests/model.property.test.ts | 14 +++++++------- src/tests/resolve.test.ts | 2 +- src/tests/splice.test.ts | 4 ++-- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/engine/structural.ts b/src/engine/structural.ts index 865cea7..3eb6b75 100644 --- a/src/engine/structural.ts +++ b/src/engine/structural.ts @@ -39,7 +39,7 @@ import { /** The subtree's first byte: a section's own marker, or its body for the root. */ const subtreeStart = (section: SectionNode): number => - section.marker ? section.marker.start : section.content.start; + section.marker ? section.marker.start : section.body.start; // --- Move ---------------------------------------------------------------- @@ -64,12 +64,12 @@ const childInsertOffset = ( ): number => { const children = newParent.children; if (place === "first") { - return children.length ? subtreeStart(children[0]) : newParent.content.end; + return children.length ? subtreeStart(children[0]) : newParent.body.end; } if (place === "last") { return children.length ? subtreeEnd(children[children.length - 1]) - : newParent.content.end; + : newParent.body.end; } const addr = "before" in place ? place.before : place.after; const sibling = resolveHeading(model, addr)?.section; diff --git a/src/model.ts b/src/model.ts index e631b29..4567f8d 100644 --- a/src/model.ts +++ b/src/model.ts @@ -24,8 +24,8 @@ export interface SectionNode { /** The heading line (`# Foo\n`); `null` for the root. */ marker: DocumentRange | null; /** The section's direct body, excluding {@link trailingGap}. */ - content: DocumentRange; - /** The blank-line separator following {@link content} that the library owns. */ + body: DocumentRange; + /** The blank-line separator following {@link body} that the library owns. */ trailingGap: DocumentRange; /** Child sections, in document order. */ children: SectionNode[]; @@ -36,7 +36,7 @@ export interface SectionNode { /** * A `^id`-bearing block. Blocks are an *overlay* onto the section tree: their - * ranges fall within their containing section's {@link SectionNode.content}, + * ranges fall within their containing section's {@link SectionNode.body}, * they do not tile the document themselves. */ export interface BlockNode { @@ -225,7 +225,7 @@ const buildSectionTree = ( const root: SectionNode = { heading: null, marker: null, - content: { start: 0, end: 0 }, + body: { start: 0, end: 0 }, trailingGap: { start: 0, end: 0 }, children: [], blocks: [], @@ -237,7 +237,7 @@ const buildSectionTree = ( const rootBodyStart = 0; const rootBodyEnd = headings.length ? headings[0].markerStart : contentLength; const rootSplit = splitTrailingGap(content, rootBodyStart, rootBodyEnd); - root.content = { start: abs(rootBodyStart), end: abs(rootSplit.contentEnd) }; + root.body = { start: abs(rootBodyStart), end: abs(rootSplit.contentEnd) }; root.trailingGap = { start: abs(rootSplit.contentEnd), end: abs(rootBodyEnd) }; const stack: SectionNode[] = [root]; @@ -256,7 +256,7 @@ const buildSectionTree = ( const node: SectionNode = { heading: { text: heading.text, level: heading.level }, marker: { start: abs(heading.markerStart), end: abs(heading.markerEnd) }, - content: { start: abs(bodyStart), end: abs(split.contentEnd) }, + body: { start: abs(bodyStart), end: abs(split.contentEnd) }, trailingGap: { start: abs(split.contentEnd), end: abs(bodyEnd) }, children: [], blocks: [], @@ -292,9 +292,9 @@ const forEachSection = ( const sectionContaining = (root: SectionNode, offset: number): SectionNode => { let best = root; forEachSection(root, (node) => { - if (offset >= node.content.start && offset < node.trailingGap.end) { + if (offset >= node.body.start && offset < node.trailingGap.end) { // Prefer the deepest (most specific) containing section. - if (node.content.start >= best.content.start) { + if (node.body.start >= best.body.start) { best = node; } } @@ -493,7 +493,7 @@ export const serializeModel = ( if (node.marker) { parts.push(document.slice(node.marker.start, node.marker.end)); } - parts.push(document.slice(node.content.start, node.content.end)); + parts.push(document.slice(node.body.start, node.body.end)); parts.push(document.slice(node.trailingGap.start, node.trailingGap.end)); for (const child of node.children) { emit(child); diff --git a/src/ranges.ts b/src/ranges.ts index 5aa41d9..5a873f9 100644 --- a/src/ranges.ts +++ b/src/ranges.ts @@ -17,7 +17,7 @@ export const lastDescendant = (section: SectionNode): SectionNode => : section; const subtreeStart = (section: SectionNode): number => - section.marker ? section.marker.start : section.content.start; + section.marker ? section.marker.start : section.body.start; /** * The subtree's visible extent: the heading line through the last descendant's @@ -35,10 +35,10 @@ export const subtreeContentRange = (section: SectionNode): DocumentRange => ({ * trailing gap. This is the whole subtree *minus* its own marker — the span 1.x * `content` addressed — so a single content read/replace round-trips a section's * full body, subsections included. For a leaf section it coincides with the - * direct body (`section.content`). + * direct body (`section.body`). */ export const headingContentRange = (section: SectionNode): DocumentRange => ({ - start: section.content.start, + start: section.body.start, end: lastDescendant(section).trailingGap.start, }); diff --git a/src/tests/model.property.test.ts b/src/tests/model.property.test.ts index 08a437e..a72b7f9 100644 --- a/src/tests/model.property.test.ts +++ b/src/tests/model.property.test.ts @@ -69,16 +69,16 @@ describe("model partition invariants", () => { if (node.marker) { expect(node.marker.start).toBeLessThanOrEqual(node.marker.end); } - expect(node.content.start).toBeLessThanOrEqual(node.content.end); + expect(node.body.start).toBeLessThanOrEqual(node.body.end); expect(node.trailingGap.start).toBeLessThanOrEqual(node.trailingGap.end); } }); test("content and trailingGap are contiguous per section", () => { for (const node of collectSections(model)) { - expect(node.content.end).toEqual(node.trailingGap.start); + expect(node.body.end).toEqual(node.trailingGap.start); if (node.marker) { - expect(node.marker.end).toEqual(node.content.start); + expect(node.marker.end).toEqual(node.body.start); } } }); @@ -93,7 +93,7 @@ describe("model partition invariants", () => { test("block ranges fall inside their containing section body", () => { for (const node of collectSections(model)) { for (const block of node.blocks) { - expect(block.content.start).toBeGreaterThanOrEqual(node.content.start); + expect(block.content.start).toBeGreaterThanOrEqual(node.body.start); expect(block.marker.end).toBeLessThanOrEqual(node.trailingGap.end); expect(block.section).toBe(node); } @@ -140,14 +140,14 @@ describe("model structure", () => { expect(model.frontmatter.block).not.toBeNull(); expect(model.frontmatter.entries.map((e) => e.key)).toEqual(["title"]); // Root content starts at or after the frontmatter block. - expect(model.root.content.start).toBeGreaterThanOrEqual( + expect(model.root.body.start).toBeGreaterThanOrEqual( model.frontmatter.block!.end ); }); }); const text_of = (doc: string, node: SectionNode): string => - doc.slice(node.content.start, node.content.end); + doc.slice(node.body.start, node.body.end); // A small deterministic PRNG so failures are reproducible from their seed. const mulberry32 = (seed: number): (() => number) => { @@ -205,7 +205,7 @@ describe("model partition fuzz", () => { // Section ranges must tile: each section's trailingGap end meets the // next boundary and gaps are whitespace only. eachSection(model.root, (node) => { - expect(node.content.end).toEqual(node.trailingGap.start); + expect(node.body.end).toEqual(node.trailingGap.start); expect(doc.slice(node.trailingGap.start, node.trailingGap.end)).toMatch( /^\s*$/ ); diff --git a/src/tests/resolve.test.ts b/src/tests/resolve.test.ts index 54c87a9..6e27f46 100644 --- a/src/tests/resolve.test.ts +++ b/src/tests/resolve.test.ts @@ -6,7 +6,7 @@ const headingLevel = (r: ResolvedTarget | null): number | null => const bodyOf = (doc: string, r: ResolvedTarget | null): string => { if (!r || r.kind !== "heading") return ""; - return doc.slice(r.section.content.start, r.section.content.end); + return doc.slice(r.section.body.start, r.section.body.end); }; describe("resolveHeading", () => { diff --git a/src/tests/splice.test.ts b/src/tests/splice.test.ts index 839bd2f..62a2c70 100644 --- a/src/tests/splice.test.ts +++ b/src/tests/splice.test.ts @@ -75,8 +75,8 @@ describe("splice no-op identity (property over fixtures)", () => { }); } edits.push({ - range: node.content, - text: doc.slice(node.content.start, node.content.end), + range: node.body, + text: doc.slice(node.body.start, node.body.end), }); }); // Edits are node markers/contents, which are disjoint and in order. From 157369dd2e7a4201ea0dc1b82be701f59e402f25 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 07:20:53 -0500 Subject: [PATCH 38/73] Add failing tests for unvalidated block ids and heading marker renames Block ids are spliced verbatim into `^id` markers (both on a marker rename and on createTargetIfMissing block creation), and heading marker renames are spliced verbatim into a single heading line. Neither rejects characters that would corrupt the emitted markdown: a block id with a space or embedded `^` doesn't parse as an Obsidian block reference, and a heading rename containing a newline injects structure into what must stay a single line. Co-Authored-By: Claude Sonnet 5 --- src/tests/schema.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/tests/schema.test.ts b/src/tests/schema.test.ts index 052e5bb..54f96d6 100644 --- a/src/tests/schema.test.ts +++ b/src/tests/schema.test.ts @@ -57,6 +57,10 @@ const valid: { name: string; instruction: InstructionInput }[] = [ name: "block marker replace", instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", content: "def" }, }, + { + name: "block target/marker with hyphens and underscores", + instruction: { targetType: "block", target: "a-b_c1", operation: "replace", scope: "marker", content: "x-y_2" }, + }, { name: "block table-row write", instruction: { targetType: "block", target: "abc", operation: "append", value: [["a", "b"]] }, @@ -162,6 +166,22 @@ describe("InstructionInputSchema", () => { name: "a value on a block marker cell (only content/value on `content` scope)", instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", value: [["a"]] }, }, + { + name: "a block target containing a space", + instruction: { targetType: "block", target: "has space", operation: "append", content: "x" }, + }, + { + name: "a block marker rename to an id containing a space", + instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", content: "new id" }, + }, + { + name: "a block marker rename to an id containing a caret", + instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", content: "^def" }, + }, + { + name: "a heading marker rename containing an embedded newline", + instruction: { targetType: "heading", target: ["A"], operation: "replace", scope: "marker", content: "New\nline" }, + }, ]; test.each(invalid)("rejects $name", ({ instruction }) => { From 0cbfc4b9fc6eef1886eb9a28c1520e4e46abf630 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 07:22:04 -0500 Subject: [PATCH 39/73] Validate block ids and reject newlines in heading marker renames A block target/rename outside [A-Za-z0-9_-]+ can never address (or produce) a real `^id` marker -- BLOCK_REFERENCE_REGEX in model.ts already limits the character set Obsidian recognizes, so any id outside it was silently spliced into unparseable markdown. Likewise a heading marker rename injected whatever text it was given directly into a single heading line, so an embedded newline silently split it into two lines. Both are now rejected at the schema boundary with a clear 400-mapped error instead of corrupting the document on write. Co-Authored-By: Claude Sonnet 5 --- src/schema.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 02abcb3..4477fc7 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -101,7 +101,7 @@ export const InstructionInputObjectSchema = z target: z .union([z.array(z.string()), z.string(), z.null()]) .describe( - "The node to edit. For a heading: an array of heading texts from the top level down to the target (e.g. [\"Overview\",\"Details\"]), or null/[] for the document root. For a block: the bare block id, without the leading `^`. For a frontmatter field: the key." + "The node to edit. For a heading: an array of heading texts from the top level down to the target (e.g. [\"Overview\",\"Details\"]), or null/[] for the document root. For a block: the bare block id, without the leading `^` (letters, numbers, hyphens, and underscores only). For a frontmatter field: the key." ), operation: z .enum(operationValues) @@ -117,7 +117,7 @@ export const InstructionInputObjectSchema = z content: z .string() .describe( - "String payload: a heading/block body or label, or a new frontmatter key name for a `marker` rename. Heading levels are relative to the edited span (a leading `#` becomes a direct child). Provide exactly one of `content`, `value`, or `destination`." + "String payload: a heading/block body or label, a new block id for a block `marker` rename (letters, numbers, hyphens, and underscores only), or a new frontmatter key name for a frontmatter `marker` rename. Heading levels are relative to the edited span (a leading `#` becomes a direct child). A heading `marker` rename may not contain a line break. Provide exactly one of `content`, `value`, or `destination`." ) .optional(), value: z @@ -182,6 +182,15 @@ const expectedCarriers = ( const carriers = ["content", "value", "destination"] as const; +/** + * A block id's allowed character set — mirrors `BLOCK_REFERENCE_REGEX` in + * `model.ts`, which is what actually recognizes a `^id` marker in the + * document. A target or rename outside this set could never address (or + * produce) a real block reference, so it is rejected here rather than + * spliced in verbatim and silently failing to parse as one. + */ +const BLOCK_ID_PATTERN = /^[A-Za-z0-9_-]+$/; + /** A 2-D array of strings: table rows for a `block` `content`-cell `value` write. */ const isTableRowValue = (value: unknown): value is string[][] => Array.isArray(value) && @@ -217,6 +226,13 @@ const instructionAlgebra = ( path: ["target"], message: `a ${targetType} target must be a string`, }); + } else if (targetType === "block" && !BLOCK_ID_PATTERN.test(target)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["target"], + message: + "a block id may contain only letters, numbers, hyphens, and underscores", + }); } // Cell validity. @@ -282,6 +298,28 @@ const instructionAlgebra = ( message: "value for a block content write must be a 2-D array of strings — one row per entry, one cell per column", }); + } else if ( + targetType === "block" && + scope === "marker" && + operation === "replace" && + !BLOCK_ID_PATTERN.test(input.content as string) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["content"], + message: + "a block id may contain only letters, numbers, hyphens, and underscores", + }); + } else if ( + targetType === "heading" && + scope === "marker" && + /[\r\n]/.test(input.content as string) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["content"], + message: "a heading marker rename cannot contain a line break", + }); } }; From e9007e3897198588421e2a047559b5fbed9c5ad7 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 07:22:32 -0500 Subject: [PATCH 40/73] Add failing tests for a heading literally named __proto__ projectMap's buildTree assigns child subtrees with a plain `into[text] = subtree`. For text === "__proto__" that invokes Object.prototype's __proto__ setter instead of creating an own property, so the heading (and everything nested under it) silently disappears from the public map even though the resolver can still address it directly by containment path. Co-Authored-By: Claude Sonnet 5 --- src/tests/projection.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/tests/projection.test.ts b/src/tests/projection.test.ts index 546609a..a2b2e65 100644 --- a/src/tests/projection.test.ts +++ b/src/tests/projection.test.ts @@ -86,6 +86,19 @@ describe("projectMap", () => { expect(map.blocks).toEqual(["a", "b"]); }); + test("a heading literally named __proto__ is a real, addressable key", () => { + const doc = "# __proto__\n\nbody\n\n## Child\n\nc\n"; + const map = projectMap(buildModel(doc)); + // A plain `into[text] = subtree` assignment for text === "__proto__" sets + // the object's prototype instead of an own property, silently dropping the + // heading from the map. It must come back as a real own, enumerable key. + expect(Object.prototype.hasOwnProperty.call(map.headings, "__proto__")).toBe( + true + ); + expect(map.headings.__proto__).toEqual({ Child: {} }); + expect(Object.keys(map.headings)).toEqual(["__proto__"]); + }); + test("headingTreePaths enumerates every address in document order", () => { const paths = headingTreePaths( projectMap(buildModel("# A\n\n## B\n\nb\n\n### C\n\nc\n\n# D\n\nd\n")).headings @@ -115,6 +128,10 @@ describe("map/resolver agreement — the tree is exactly the addressable set", ( }, { name: "empty heading text", document: "# \n\nbody\n\n## Child\n\nc\n" }, { name: "no headings at all", document: "just prose\n" }, + { + name: "__proto__ as heading text", + document: "# __proto__\n\nbody\n\n## Child\n\nc\n", + }, ]; test.each(documents)("$name", ({ document }) => { From 77df34c0fe31d44e3882dd8ca7b05d811544ec71 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 07:23:02 -0500 Subject: [PATCH 41/73] Give heading-tree nodes a null prototype to fix the __proto__ collision projectMap built each HeadingTree node as a plain {} object literal and assigned children with `into[text] = subtree`. For a heading literally named "__proto__", that bracket assignment hits Object.prototype's `__proto__` accessor instead of creating an own property, so the heading -- and its whole subtree -- silently vanished from the public map (and from anything downstream that JSON-serializes it), even though the resolver could still reach it directly by containment path. Object.create(null) removes the accessor entirely, so every heading text becomes an ordinary own key regardless of its spelling. Co-Authored-By: Claude Sonnet 5 --- src/projection.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/projection.ts b/src/projection.ts index 9e29d78..d289232 100644 --- a/src/projection.ts +++ b/src/projection.ts @@ -58,7 +58,11 @@ export const headingPath = (node: SectionNode): string[] => { /** Project the internal model into the public map consumers receive. */ export const projectMap = (model: DocumentModel): PublicMap => { - const headings: HeadingTree = {}; + // Null-prototype: a heading literally named "__proto__" must become a real + // own key. On an ordinary object literal, `into[text] = subtree` for that + // text invokes Object.prototype's `__proto__` setter instead, silently + // discarding the heading. + const headings: HeadingTree = Object.create(null) as HeadingTree; const blocks: string[] = []; // Blocks are addressed globally by bare id, so every block is listed in @@ -86,7 +90,7 @@ export const projectMap = (model: DocumentModel): PublicMap => { const existing = Object.prototype.hasOwnProperty.call(into, text) ? into[text] : undefined; - const subtree: HeadingTree = existing ?? {}; + const subtree: HeadingTree = existing ?? (Object.create(null) as HeadingTree); if (!existing) { into[text] = subtree; } From 24cc5189e20e2d3a49196aa2d4ed0b74100c622f Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 07:30:45 -0500 Subject: [PATCH 42/73] Add a failing test for findBlocks mis-anchoring identical blocks findBlocks searches for each token's raw text with `content.indexOf(token.raw, searchFrom)`, then sets `searchFrom = found` -- the token's own *start*, not its end. For most constructs that accidentally still lands on the right place (a blank-line separator or a nested child token happens to nudge the shared cursor forward first), but fenced code blocks need no blank-line separator and are leaf tokens with no children to do that nudging. Three back-to-back identical fences followed by an isolated `^ref` line resolve the reference to the *first* fence instead of the third (nearest) one, matching the review's description of the bug (inherited from 1.x's lastBlockDetails, not a regression, but worth fixing now that the model is being rebuilt). Co-Authored-By: Claude Sonnet 5 --- src/tests/blocks.test.ts | 74 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/tests/blocks.test.ts diff --git a/src/tests/blocks.test.ts b/src/tests/blocks.test.ts new file mode 100644 index 0000000..cb681af --- /dev/null +++ b/src/tests/blocks.test.ts @@ -0,0 +1,74 @@ +import { buildModel, eachSection, BlockNode } from "../model"; + +const findById = (model: ReturnType, id: string): BlockNode | undefined => { + let found: BlockNode | undefined; + eachSection(model.root, (n) => + n.blocks.forEach((b) => { + if (b.id === id) found = b; + }) + ); + return found; +}; + +describe("findBlocks anchoring on duplicate blocks", () => { + test("an isolated ^id binds to the nearer of two textually-identical preceding blocks", () => { + // Two identical paragraphs, then an isolated `^ref` line: Obsidian's rule + // is that it targets the immediately preceding block (the second "foo"), + // not whichever occurrence `indexOf` happens to re-match first. + const doc = "foo\n\nfoo\n\n^ref\n"; + const model = buildModel(doc); + const firstFoo = doc.indexOf("foo"); + const secondFoo = doc.indexOf("foo", firstFoo + 1); + + const block = findById(model, "ref"); + expect(block).toBeDefined(); + expect(block!.isolated).toBe(true); + expect(block!.content.start).toBe(secondFoo); + expect(doc.slice(block!.content.start, block!.content.end)).toBe("foo"); + }); + + test("three identical paragraphs each anchor to their own occurrence", () => { + const doc = "foo\n\nfoo\n\nfoo ^a\n"; + const model = buildModel(doc); + const thirdFoo = doc.lastIndexOf("foo"); + + const block = findById(model, "a"); + expect(block).toBeDefined(); + expect(block!.isolated).toBe(false); + expect(block!.content.start).toBe(thirdFoo); + }); + + test("an isolated ^id binds to the nearest of three byte-identical, unseparated code blocks", () => { + // Fenced code blocks need no blank-line separator between them, and marked + // emits them as leaf tokens (no nested child token to nudge the shared + // search cursor forward either) -- so three back-to-back identical fences + // are the case that actually exposes `indexOf(raw, searchFrom)` re-matching + // the *first* occurrence for every later one, since nothing ever advances + // the cursor past it. + const fence = "```\nfoo\n```\n"; + const doc = fence + fence + "```\nfoo\n```" + "\n\n^ref\n"; + const model = buildModel(doc); + const thirdFenceStart = doc.lastIndexOf("```\nfoo\n```"); + + const block = findById(model, "ref"); + expect(block).toBeDefined(); + expect(block!.isolated).toBe(true); + expect(block!.content.start).toBe(thirdFenceStart); + expect(doc.slice(block!.content.start, block!.content.end)).toBe( + "```\nfoo\n```" + ); + }); + + test("two duplicate blocks each carrying their own inline id resolve to distinct spans", () => { + const doc = "same text ^one\n\nsame text ^two\n"; + const model = buildModel(doc); + + const one = findById(model, "one"); + const two = findById(model, "two"); + expect(one).toBeDefined(); + expect(two).toBeDefined(); + expect(one!.content.start).toBeLessThan(two!.content.start); + expect(doc.slice(one!.content.start, one!.content.end)).toBe("same text"); + expect(doc.slice(two!.content.start, two!.content.end)).toBe("same text"); + }); +}); From 4db394d7f55061cce56c3c5e4f0e3c7b480be859 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 07:31:42 -0500 Subject: [PATCH 43/73] Fix findBlocks mis-anchoring byte-identical sibling blocks findBlocks located each token by `content.indexOf(token.raw, searchFrom)` and then set `searchFrom = found` -- the token's own start, never advancing past it. For most markdown constructs this happened to self-correct (a mandatory blank-line separator or a nested child token nudged the shared cursor forward before the next sibling was searched), but nothing guaranteed it: fenced code blocks need no blank line between them and are leaf tokens with nothing to search inside, so back-to-back identical fences all re-matched the position of the first one. Replaced the flat `marked.walkTokens` callback with a recursive walker that mirrors its own traversal order (table header/rows, list items, then the generic `.tokens` case) but tracks the search floor correctly: a token's *next sibling* never anchors earlier than that token's own end (plus everything found under it), while its *descendants* still search starting at the token's own start, since a child's raw is a substring of its parent's and can legitimately begin at the same offset. This makes correct anchoring a property of the algorithm rather than an accident of which tokens happen to have distinguishing text between them. Co-Authored-By: Claude Sonnet 5 --- src/model.ts | 64 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/src/model.ts b/src/model.ts index 4567f8d..448a9de 100644 --- a/src/model.ts +++ b/src/model.ts @@ -326,23 +326,13 @@ const findBlocks = ( root: SectionNode ): BlockNode[] => { const blocks: BlockNode[] = []; - let searchFrom = 0; // The most recent block-level token an isolated `^id` line can bind to, in // content space with its trailing newline stripped. Mirrors the old engine's // `lastBlockDetails` and matches Obsidian, which reports an isolated block's // position as the preceding block rather than the marker line. let lastIsolatedTarget: { start: number; end: number } | null = null; - marked.walkTokens(tokens, (token) => { - const found = content.indexOf(token.raw, searchFrom); - if (found === -1) { - // Inner blockquote tokens omit their `> ` prefix and never appear - // verbatim; skip them rather than corrupt the running offset. - return; - } - searchFrom = found; - const rawEnd = found + token.raw.length; - + const visit = (token: marked.Token, found: number, rawEnd: number): void => { const match = BLOCK_REFERENCE_REGEX.exec(token.raw); if (match && CAN_INCLUDE_BLOCK_REFERENCE.includes(token.type)) { const id = match[1]; @@ -384,7 +374,57 @@ const findBlocks = ( end: stripTrailingEol(content, rawEnd, found), }; } - }); + }; + + /** + * Walk a sibling token array in the same pre-order `marked.walkTokens` + * uses (a token, then its own children, before its next sibling), + * mirroring its `table`/`list`/default child dispatch. A sibling never + * anchors earlier than `floor`, and — critically — the floor handed to the + * *next* sibling only advances past everything the current token (and its + * descendants) consumed, so two byte-identical sibling tokens (e.g. two + * back-to-back fenced code blocks with no blank line between them) each + * anchor to their own occurrence instead of both collapsing onto the + * first. Descendants still search starting at their own parent's start, + * since a child's raw is a substring of its parent's and may begin at the + * same offset. + */ + const walk = (tokensArray: readonly marked.Token[], floor: number): number => { + for (const token of tokensArray) { + const found = content.indexOf(token.raw, floor); + if (found === -1) { + // Inner blockquote tokens omit their `> ` prefix and never appear + // verbatim; skip them rather than corrupt the running floor. + continue; + } + const rawEnd = found + token.raw.length; + visit(token, found, rawEnd); + + let childFloor = found; + if (token.type === "table") { + const table = token as marked.Tokens.Table; + for (const cell of table.header) { + childFloor = walk(cell.tokens, childFloor); + } + for (const row of table.rows) { + for (const cell of row) { + childFloor = walk(cell.tokens, childFloor); + } + } + } else if (token.type === "list") { + childFloor = walk((token as marked.Tokens.List).items, childFloor); + } else { + const children = (token as { tokens?: marked.Token[] }).tokens; + if (children) { + childFloor = walk(children, childFloor); + } + } + floor = Math.max(rawEnd, childFloor); + } + return floor; + }; + + walk(tokens, 0); return blocks; }; From fb139baf54c19d39889ef9a944b4d6487e446fba Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 08:35:15 -0500 Subject: [PATCH 44/73] Add failing tests for duplicate sibling heading addressing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2.0 map (projection.ts) currently merges same-named sibling headings into one shared subtree, and the resolver matches the first section in document order — so a second "## Notes" under the same parent is permanently unreachable. These tests assert the target behavior instead: each occurrence gets its own key/address, with the 2nd+ occurrence's key carrying a reserved private-use-codepoint marker suffix (never written into the document itself) so no two sections ever share an address. Also adds the ReservedDuplicateMarkerError type (scaffolding only, no validation logic yet) so the new guard tests can reference it. Co-Authored-By: Claude Sonnet 5 --- src/index.ts | 1 + src/instructions.ts | 10 ++++ src/tests/duplicateHeadingMarker.test.ts | 34 +++++++++++ src/tests/projection.test.ts | 73 ++++++++++++++++++------ src/tests/resolve.test.ts | 20 ++++++- 5 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 src/tests/duplicateHeadingMarker.test.ts diff --git a/src/index.ts b/src/index.ts index febfd93..e15d642 100755 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ export { MergeError, FrontmatterParseError, FrontmatterKeyCollisionError, + ReservedDuplicateMarkerError, isValidCell, assertValidCell, isBlockTableRowInstruction, diff --git a/src/instructions.ts b/src/instructions.ts index 86a034a..66b1af7 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -330,6 +330,16 @@ export class FrontmatterParseError extends EngineError {} */ export class FrontmatterKeyCollisionError extends EngineError {} +/** + * A heading or block id, as written in the source document, already ends with + * the exact reserved sequence the engine uses to disambiguate a duplicate + * sibling heading's address (see {@link disambiguatedHeadingText} in + * `projection.ts`). Left unguarded, this could make a synthesized disambiguated + * address collide with real document content and silently resolve to the + * wrong section. + */ +export class ReservedDuplicateMarkerError extends EngineError {} + /** Throw {@link InvalidCellError} unless the cell is part of the algebra. */ export const assertValidCell = (cell: Cell): void => { if (!isValidCell(cell.targetType, cell.operation, cell.scope)) { diff --git a/src/tests/duplicateHeadingMarker.test.ts b/src/tests/duplicateHeadingMarker.test.ts new file mode 100644 index 0000000..09e5725 --- /dev/null +++ b/src/tests/duplicateHeadingMarker.test.ts @@ -0,0 +1,34 @@ +import { buildModel } from "../model"; +import { ReservedDuplicateMarkerError } from "../instructions"; + +describe("reserved duplicate-marker collision guard", () => { + test("throws when a heading's raw text ends with the marker followed by digits", () => { + const doc = `# Heading\u{FC750}\u{F6440}\n\nbody\n`; + expect(() => buildModel(doc)).toThrow(ReservedDuplicateMarkerError); + }); + + test("throws when a heading's raw text ends with the marker followed by multiple digits", () => { + const doc = `# Heading\u{FC750}\u{F6441}\u{F6440}\n\nbody\n`; + expect(() => buildModel(doc)).toThrow(ReservedDuplicateMarkerError); + }); + + test("throws when a block id ends with the marker followed by digits", () => { + const doc = `paragraph text ^ref\u{FC750}\u{F6440}\n`; + expect(() => buildModel(doc)).toThrow(ReservedDuplicateMarkerError); + }); + + test("does not throw when the marker sequence sits in the middle of a heading", () => { + const doc = `# Heading\u{FC750}\u{F6440} and more text\n\nbody\n`; + expect(() => buildModel(doc)).not.toThrow(); + }); + + test("does not throw when the marker appears with no digits after it", () => { + const doc = `# Heading\u{FC750}\n\nbody\n`; + expect(() => buildModel(doc)).not.toThrow(); + }); + + test("does not throw for an ordinary document with no reserved codepoints", () => { + const doc = `# Heading\n\nbody ^ref\n`; + expect(() => buildModel(doc)).not.toThrow(); + }); +}); diff --git a/src/tests/projection.test.ts b/src/tests/projection.test.ts index a2b2e65..11f75d9 100644 --- a/src/tests/projection.test.ts +++ b/src/tests/projection.test.ts @@ -50,42 +50,78 @@ describe("projectMap", () => { expect(map.frontmatterFields).toEqual([]); }); - test("a repeated sibling name merges its children into one subtree", () => { + test("a repeated sibling name gets a distinct key per occurrence", () => { const doc = "## Log\n\n### Monday\n\nm\n\n## Log\n\n### Tuesday\n\nt\n"; const map = projectMap(buildModel(doc)); - // "Log" is one key, but Tuesday has its own containment path and so is its - // own address — dropping it would hide a heading the resolver can reach. - expect(map.headings).toEqual({ Log: { Monday: {}, Tuesday: {} } }); + // The first "Log" keeps its plain text; the second gets a marker suffix + // so both are separately addressable, each with its own subtree. + const secondLog = "Log\u{FC750}\u{F6440}"; + expect(map.headings).toEqual({ + Log: { Monday: {} }, + [secondLog]: { Tuesday: {} }, + }); }); - test("sections that genuinely share a path collapse to one address", () => { + test("sections that would have collided on path alone now get distinct addresses", () => { const doc = "## Log\n\n### Monday\n\nfirst\n\n## Log\n\n### Monday\n\nsecond\n"; const map = projectMap(buildModel(doc)); - // Both Mondays are ["Log", "Monday"]; that is one address, and it resolves - // to the first in document order. - expect(map.headings).toEqual({ Log: { Monday: {} } }); + // Both "Log"s have a "Monday" child, but the second "Log" is itself + // disambiguated, so the two Mondays end up on distinct paths. + const secondLog = "Log\u{FC750}\u{F6440}"; + expect(map.headings).toEqual({ + Log: { Monday: {} }, + [secondLog]: { Monday: {} }, + }); }); - test("a repeat's descendants merge even below a shared path", () => { + test("a repeat's descendants nest under the repeat's own disambiguated key", () => { const doc = "# A\n\n## X\n\nfirst\n\n# A\n\n## X\n\n### Z\n\nz\n"; const map = projectMap(buildModel(doc)); - // ["A","X"] is shared, but ["A","X","Z"] is unique and stays addressable. - expect(map.headings).toEqual({ A: { X: { Z: {} } } }); + const secondA = "A\u{FC750}\u{F6440}"; + expect(map.headings).toEqual({ + A: { X: {} }, + [secondA]: { X: { Z: {} } }, + }); }); - test("a block under a repeated heading is still listed", () => { + test("a block under a repeated heading is still listed, and each heading gets its own key", () => { const doc = "## Log\n\nfirst ^a\n\n## Log\n\nsecond ^b\n"; const map = projectMap(buildModel(doc)); - // Neither "Log" has child headings, so the tree has one leaf; blocks are - // addressed globally and both stay listed. - expect(map.headings).toEqual({ Log: {} }); + // Blocks are addressed globally and both stay listed regardless. + const secondLog = "Log\u{FC750}\u{F6440}"; + expect(map.headings).toEqual({ Log: {}, [secondLog]: {} }); expect(map.blocks).toEqual(["a", "b"]); }); + test("a third occurrence advances the hex digit", () => { + const doc = "## Dup\n\na\n\n## Dup\n\nb\n\n## Dup\n\nc\n"; + const map = projectMap(buildModel(doc)); + expect(Object.keys(map.headings)).toEqual([ + "Dup", + "Dup\u{FC750}\u{F6440}", + "Dup\u{FC750}\u{F6441}", + ]); + }); + + test("occurrence indexes past 16 cross into two hex digits", () => { + const lines: string[] = []; + for (let i = 0; i < 18; i++) { + lines.push("## Dup", "", `body ${i}`, ""); + } + const map = projectMap(buildModel(lines.join("\n"))); + const keys = Object.keys(map.headings); + expect(keys).toHaveLength(18); + // The 17th occurrence (index 15, hex "f") is the last representable in + // a single reserved digit. + expect(keys[16]).toBe("Dup\u{FC750}\u{F644F}"); + // The 18th occurrence (index 16, hex "10") is the first that needs two. + expect(keys[17]).toBe("Dup\u{FC750}\u{F6441}\u{F6440}"); + }); + test("a heading literally named __proto__ is a real, addressable key", () => { const doc = "# __proto__\n\nbody\n\n## Child\n\nc\n"; const map = projectMap(buildModel(doc)); @@ -126,6 +162,10 @@ describe("map/resolver agreement — the tree is exactly the addressable set", ( name: "repeat nested below a shared path", document: "# A\n\n## X\n\nfirst\n\n# A\n\n## X\n\n### Z\n\nz\n", }, + { + name: "three siblings with the same text", + document: "## Dup\n\na\n\n## Dup\n\nb\n\n## Dup\n\nc\n", + }, { name: "empty heading text", document: "# \n\nbody\n\n## Child\n\nc\n" }, { name: "no headings at all", document: "just prose\n" }, { @@ -144,7 +184,8 @@ describe("map/resolver agreement — the tree is exactly the addressable set", ( } // ...and every heading in the document is advertised, so nothing reachable - // is hidden. Sections sharing a path are one address, hence the dedup. + // is hidden. Every section now gets its own disambiguated address, so the + // advertised and actual sets should match one-for-one, with no dedup. const actual: string[][] = []; eachSection(model.root, (node) => { if (node.heading) { diff --git a/src/tests/resolve.test.ts b/src/tests/resolve.test.ts index 6e27f46..6a3ef55 100644 --- a/src/tests/resolve.test.ts +++ b/src/tests/resolve.test.ts @@ -1,5 +1,6 @@ import { buildModel } from "../model"; import { resolveTarget, resolveHeading, ResolvedTarget } from "../resolve"; +import { headingPath } from "../projection"; const headingLevel = (r: ResolvedTarget | null): number | null => r && r.kind === "heading" && r.section.heading ? r.section.heading.level : null; @@ -30,18 +31,31 @@ describe("resolveHeading", () => { expect(bodyOf(dupDoc, r)).toContain("body b1"); }); - test("duplicate headings resolve to the first in document order", () => { + test("a bare, unsuffixed address uniquely names the first occurrence", () => { const model = buildModel(dupDoc); const r = resolveHeading(model, ["A"]); expect(headingLevel(r)).toBe(1); - // The first "A" owns the h2 B subtree; its direct body is empty, but the - // node identity is the first occurrence. + // The second "A" now has its own disambiguated address (see the next + // test), so ["A"] is no longer "first wins among ambiguous matches" — it + // unambiguously names only the first occurrence. expect(r?.kind).toBe("heading"); if (r?.kind === "heading") { expect(r.section).toBe(model.root.children[0]); } }); + test("a repeated heading's later occurrence resolves via its disambiguated address", () => { + const model = buildModel(dupDoc); + const secondA = model.root.children[1]; + const address = headingPath(secondA); + expect(address).toEqual(["A\u{FC750}\u{F6440}"]); + const r = resolveHeading(model, address); + expect(r?.kind).toBe("heading"); + if (r?.kind === "heading") { + expect(r.section).toBe(secondA); + } + }); + test("containment path spans a skipped level (garden path)", () => { const doc = ["# Over", "### Quirk", "x", ""].join("\n"); const model = buildModel(doc); From c04b8ce49bac4111b9c6fd3314b22795501ff326 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 08:39:02 -0500 Subject: [PATCH 45/73] Give every duplicate sibling heading its own address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildTree previously merged same-named sibling headings into one shared subtree, and resolveHeading matched the first section in document order with a given containment path — so a second "## Notes" under the same parent was permanently unreachable through the 2.0 map/resolver. Each occurrence now gets its own key/address: the first keeps its plain heading text, and each later occurrence's key has a suffix appended made of reserved Supplementary PUA-A codepoints (never written into the document itself, only synthesized into the ephemeral map/target address) encoding its 0-based occurrence index in hex. headingPath and buildTree both derive this from the same disambiguatedHeadingText function, so resolution and map projection can never drift apart, and no separate decode step is ever needed — an address is just matched by plain string equality. This also means resolveHeading's old "duplicates resolve to the first match" behavior no longer applies: every heading-bearing section now has a unique address. buildModel guards against a raw heading text already colliding with the reserved marker sequence, throwing ReservedDuplicateMarkerError, so a pathological document fails loudly instead of ever risking a silent wrong-section resolution. No equivalent guard is needed for block ids: Obsidian's block-id syntax (`[a-zA-Z0-9_-]+`) cannot contain these codepoints in the first place. Co-Authored-By: Claude Sonnet 5 --- src/constants.ts | 36 ++++++++++++ src/model.ts | 26 ++++++++- src/projection.ts | 71 +++++++++++++++++------- src/resolve.ts | 11 ++-- src/tests/duplicateHeadingMarker.test.ts | 7 ++- src/tests/projection.test.ts | 9 ++- 6 files changed, 130 insertions(+), 30 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 7ae8952..ed5270e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,3 +11,39 @@ export const TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE = [ export const CAN_INCLUDE_BLOCK_REFERENCE = ["paragraph", "list_item", "table"]; export const DEFAULT_TARGET_SCOPE = "content"; + +/** + * Reserved codepoints used to disambiguate a duplicate sibling heading's + * address — never written into a document, only synthesized into an + * ephemeral map key or target address. Both live in Supplementary Private + * Use Area-A (U+F0000-U+FFFFD), well clear of the plane's start (Material + * Design Icons claims U+F0001+) and of known BMP PUA clusters (legacy + * Microsoft symbol fonts, Font Awesome, Octicons, the Apple logo glyph), + * and far enough apart from each other that no plausible contiguous foreign + * assignment could span both. + * + * DUPLICATE_DIGITS is 16 sequential codepoints — an arbitrary reserved + * alphabet, not real Unicode digits — so an occurrence index is encoded in + * hex, one reserved codepoint per hex digit, via {@link encodeOccurrenceSuffix} + * in projection.ts. + */ +export const DUPLICATE_MARKER = String.fromCodePoint(0xfc750); + +export const DUPLICATE_DIGITS: readonly string[] = Array.from( + { length: 16 }, + (_, index) => String.fromCodePoint(0xf6440 + index) +); + +/** + * Matches a string ending in {@link DUPLICATE_MARKER} immediately followed by + * one or more {@link DUPLICATE_DIGITS} codepoints, with nothing after — the + * exact shape a synthesized disambiguation suffix takes. Used to guard + * against a raw heading or block id in the source document colliding with a + * synthesized address. The `u` flag is required: these are astral + * codepoints, and without it the character class would match surrogate code + * units rather than whole codepoints. + */ +export const DUPLICATE_MARKER_SUFFIX = new RegExp( + `${DUPLICATE_MARKER}[${DUPLICATE_DIGITS.join("")}]+$`, + "u" +); diff --git a/src/model.ts b/src/model.ts index 448a9de..bc52283 100644 --- a/src/model.ts +++ b/src/model.ts @@ -5,9 +5,10 @@ import { createHash } from "crypto"; import { DocumentRange } from "./types.js"; import { CAN_INCLUDE_BLOCK_REFERENCE, + DUPLICATE_MARKER_SUFFIX, TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE, } from "./constants.js"; -import { FrontmatterParseError } from "./instructions.js"; +import { FrontmatterParseError, ReservedDuplicateMarkerError } from "./instructions.js"; /** * A single section of a document: a heading plus the body that belongs @@ -429,6 +430,28 @@ const findBlocks = ( return blocks; }; +/** + * A raw heading in the source document that already ends with the exact + * reserved sequence used to disambiguate a duplicate sibling heading's + * address (see {@link disambiguatedHeadingText} in projection.ts) could + * collide with a synthesized address and silently resolve to the wrong + * section. This is checked once, here, so every consumer (map projection, + * read, patch) is protected uniformly rather than just the map endpoint. + * + * Block ids get no such check: {@link BLOCK_REFERENCE_REGEX} constrains a + * block id to `[a-zA-Z0-9_-]+`, which cannot contain these (astral, + * non-ASCII) codepoints in the first place. + */ +const assertNoReservedMarkerCollisions = (headings: HeadingSpan[]): void => { + for (const heading of headings) { + if (DUPLICATE_MARKER_SUFFIX.test(heading.text)) { + throw new ReservedDuplicateMarkerError( + `Heading "${heading.text}" ends with a sequence reserved for addressing duplicate headings; rename it to avoid ambiguous addressing.` + ); + } + } +}; + const findLineEnding = (document: string): "\n" | "\r\n" => document.indexOf("\r\n") > -1 ? "\r\n" : "\n"; @@ -505,6 +528,7 @@ export const buildModel = (document: string): DocumentModel => { const headings = findHeadings(normalized, tokens); const root = buildSectionTree(normalized, abs, headings); findBlocks(normalized, abs, tokens, root); + assertNoReservedMarkerCollisions(headings); return { version: versionOf(document), diff --git a/src/projection.ts b/src/projection.ts index d289232..03bc6f5 100644 --- a/src/projection.ts +++ b/src/projection.ts @@ -1,4 +1,5 @@ import { DocumentModel, SectionNode } from "./model.js"; +import { DUPLICATE_DIGITS, DUPLICATE_MARKER } from "./constants.js"; /** * A nested map of heading text to its child headings, mirroring the document's @@ -7,14 +8,13 @@ import { DocumentModel, SectionNode } from "./model.js"; * (an `h1` followed directly by an `h3`) does not appear as a hole — the engine * owns depth and a consumer never needs it. * - * Sibling headings are keyed by text, so a repeated sibling name appears once. - * A repeat is not dropped, though — its children merge into the first - * occurrence's subtree — because the resolver addresses a heading by its whole - * containment path, not by its name. Two sections that share a path really are - * one address and resolve to the first in document order, while a - * uniquely-pathed descendant of a repeat is its own address and is listed. For + * Sibling headings are keyed by text, and every occurrence gets its own key: a + * repeated sibling name is not merged. The first occurrence keeps its plain + * text; each later occurrence's key has a {@link disambiguatedHeadingText} + * suffix appended, so no two sections ever share an address. For * `## Log / ### Monday` followed by `## Log / ### Tuesday`, the tree is - * `{ Log: { Monday: {}, Tuesday: {} } }`: both are separately addressable. + * `{ Log: { Monday: {} }, "Log": { Tuesday: {} } }`: both "Log"s, and + * both children, are separately addressable. * * The tree therefore enumerates exactly the addresses the resolver accepts — * see {@link headingTreePaths}. @@ -23,6 +23,43 @@ export interface HeadingTree { [headingText: string]: HeadingTree; } +/** Encode a 0-based occurrence index as hex, one reserved digit per hex character. */ +const encodeOccurrenceSuffix = (occurrenceIndex: number): string => + occurrenceIndex + .toString(16) + .split("") + .map((hexChar) => DUPLICATE_DIGITS[parseInt(hexChar, 16)]) + .join(""); + +/** + * The text a heading-bearing section is keyed/addressed by: its raw heading + * text for the first occurrence among same-text siblings under the same + * parent, or that text plus a reserved-codepoint suffix for each later + * occurrence (in document order). This is the single source of truth both + * {@link headingPath} (resolution) and {@link projectMap} (the map a caller + * reads) derive from, so the two can never drift apart — an address is always + * matched by plain string equality against a freshly recomputed value, never + * decoded. Keys produced here are guaranteed unique among a parent's children + * as long as no raw heading text already collides with a synthesized suffix, + * which {@link buildModel} guards against. + */ +export const disambiguatedHeadingText = (node: SectionNode): string => { + const heading = node.heading; + if (!heading) { + return ""; + } + if (!node.parent) { + return heading.text; + } + const siblings = node.parent.children.filter( + (sibling) => sibling.heading?.text === heading.text + ); + const occurrence = siblings.indexOf(node); + return occurrence <= 0 + ? heading.text + : heading.text + DUPLICATE_MARKER + encodeOccurrenceSuffix(occurrence - 1); +}; + /** * The terse, context-cheap public view of a document, derived from the * {@link DocumentModel}. It carries no in-band grammar: headings nest by @@ -50,7 +87,7 @@ export const headingPath = (node: SectionNode): string[] => { const path: string[] = []; let current: SectionNode | null = node; while (current && current.heading) { - path.push(current.heading.text); + path.push(disambiguatedHeadingText(current)); current = current.parent; } return path.reverse(); @@ -77,23 +114,17 @@ export const projectMap = (model: DocumentModel): PublicMap => { }; collectBlocks(model.root); - // Headings nest by containment. A repeated sibling name reuses the existing - // subtree rather than starting a second one, so the repeat's descendants — - // which carry their own distinct containment paths — stay listed. This is - // what keeps the tree equal to the set of addresses the resolver accepts. + // Headings nest by containment. Every occurrence of a repeated sibling name + // gets its own key via disambiguatedHeadingText, so — given buildModel's + // collision guard — keys are unique by construction and no merge is needed. const buildTree = (node: SectionNode, into: HeadingTree): void => { for (const child of node.children) { if (!child.heading) { continue; } - const { text } = child.heading; - const existing = Object.prototype.hasOwnProperty.call(into, text) - ? into[text] - : undefined; - const subtree: HeadingTree = existing ?? (Object.create(null) as HeadingTree); - if (!existing) { - into[text] = subtree; - } + const key = disambiguatedHeadingText(child); + const subtree: HeadingTree = Object.create(null) as HeadingTree; + into[key] = subtree; buildTree(child, subtree); } }; diff --git a/src/resolve.ts b/src/resolve.ts index b5416f5..447436d 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -2,8 +2,11 @@ * Turn a public target address back into the model node it names. Headings are * matched by their containment path (see {@link headingPath}) — the ancestor * heading texts from the top level down, ignoring source depth — so a skipped - * level needs no annotation. Duplicates resolve to the first match in document - * order; the `ifMatch` precondition guards against staleness. + * level needs no annotation. A duplicate sibling heading no longer collides + * with an earlier one: {@link headingPath} disambiguates each occurrence past + * the first, so every heading-bearing section has its own unique address and + * `.find()` below matches at most one. The `ifMatch` precondition still guards + * against staleness between reading a target and applying an edit. */ import { @@ -47,8 +50,8 @@ export const resolveHeading = ( const sections = headingSections(model); // Match by containment path, ignoring source depth, so a plain address finds - // its section even across a skipped heading level. The first match in - // document order wins, so a repeated heading resolves to its first occurrence. + // its section even across a skipped heading level. Disambiguated addresses + // make every section's path unique, so `.find()` matches at most one. const match = sections.find((section) => arrayEquals(headingPath(section), target) ); diff --git a/src/tests/duplicateHeadingMarker.test.ts b/src/tests/duplicateHeadingMarker.test.ts index 09e5725..1052e36 100644 --- a/src/tests/duplicateHeadingMarker.test.ts +++ b/src/tests/duplicateHeadingMarker.test.ts @@ -12,9 +12,12 @@ describe("reserved duplicate-marker collision guard", () => { expect(() => buildModel(doc)).toThrow(ReservedDuplicateMarkerError); }); - test("throws when a block id ends with the marker followed by digits", () => { + test("a block id cannot contain the reserved marker, so this never throws", () => { + // Obsidian's block-id syntax only allows [a-zA-Z0-9_-]; a "^id" carrying + // these (astral) codepoints simply isn't recognized as a block reference + // at all, so there is nothing here for the guard to catch. const doc = `paragraph text ^ref\u{FC750}\u{F6440}\n`; - expect(() => buildModel(doc)).toThrow(ReservedDuplicateMarkerError); + expect(() => buildModel(doc)).not.toThrow(); }); test("does not throw when the marker sequence sits in the middle of a heading", () => { diff --git a/src/tests/projection.test.ts b/src/tests/projection.test.ts index 11f75d9..ff654eb 100644 --- a/src/tests/projection.test.ts +++ b/src/tests/projection.test.ts @@ -20,11 +20,14 @@ describe("projectMap", () => { const map = projectMap(buildModel(doc)); expect(map.frontmatterFields).toEqual(["status", "reviewers"]); - // Headings nest by containment (the skipped h2 leaves no hole), and the two - // "2026-07-18" siblings collapse to one key — first-wins. + // Headings nest by containment (the skipped h2 leaves no hole), and the + // second "2026-07-18" gets its own disambiguated key. expect(map.headings).toEqual({ Overview: { "Known quirks": {} }, - "Development Logs": { "2026-07-18": {} }, + "Development Logs": { + "2026-07-18": {}, + "2026-07-18\u{FC750}\u{F6440}": {}, + }, }); expect(map.blocks).toEqual(["thesis", "quirks"]); expect(map.version).toMatch(/^[0-9a-f]{6}$/); From efbfc3eb6277b6862dfc74a6001f4d4aa7a5a061 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 08:47:45 -0500 Subject: [PATCH 46/73] Document the duplicate-heading marker suffix in the target field description vault_patch (and any other consumer of InstructionInputObjectSchema) gets this text verbatim as the target field's schema description. Explains that a duplicate sibling heading's address carries a non-printable marker suffix that must be copied verbatim rather than typed by hand, matching the note already added to vault_read's hand-written description in obsidian-local-rest-api. Co-Authored-By: Claude Sonnet 5 --- src/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schema.ts b/src/schema.ts index 4477fc7..c222a7c 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -101,7 +101,7 @@ export const InstructionInputObjectSchema = z target: z .union([z.array(z.string()), z.string(), z.null()]) .describe( - "The node to edit. For a heading: an array of heading texts from the top level down to the target (e.g. [\"Overview\",\"Details\"]), or null/[] for the document root. For a block: the bare block id, without the leading `^` (letters, numbers, hyphens, and underscores only). For a frontmatter field: the key." + "The node to edit. For a heading: an array of heading texts from the top level down to the target (e.g. [\"Overview\",\"Details\"]), or null/[] for the document root. If a heading is a duplicate of an earlier sibling under the same parent, its address carries an extra non-printable marker suffix appended by the server — copy that address verbatim from wherever the document's heading structure was discovered, never retype or reconstruct it. For a block: the bare block id, without the leading `^` (letters, numbers, hyphens, and underscores only). For a frontmatter field: the key." ), operation: z .enum(operationValues) From 75c6aabb4b5af1c2a13fcd0c038c5a2de210c53c Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 09:05:19 -0500 Subject: [PATCH 47/73] Add failing tests for duplicate block-id addressing Mirrors the duplicate-heading fix: resolveBlock currently matches the first block in document order for a given id, and projectMap's blocks list pushes every block's raw id verbatim, so two blocks sharing an id show up as the literal same string twice with no way to address the second one. These tests assert the target behavior instead: the first occurrence keeps its bare id, and each later occurrence gets a reserved-marker suffix, matching the scheme already implemented for duplicate sibling headings. Co-Authored-By: Claude Sonnet 5 --- src/tests/projection.test.ts | 10 ++++++++++ src/tests/resolve.test.ts | 22 +++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/tests/projection.test.ts b/src/tests/projection.test.ts index ff654eb..9dfec0f 100644 --- a/src/tests/projection.test.ts +++ b/src/tests/projection.test.ts @@ -110,6 +110,16 @@ describe("projectMap", () => { ]); }); + test("a duplicate block id gets its own disambiguated entry in the blocks list", () => { + const doc = ["first ^dup", "", "second ^dup", "", "third ^dup", ""].join("\n"); + const map = projectMap(buildModel(doc)); + expect(map.blocks).toEqual([ + "dup", + "dup\u{FC750}\u{F6440}", + "dup\u{FC750}\u{F6441}", + ]); + }); + test("occurrence indexes past 16 cross into two hex digits", () => { const lines: string[] = []; for (let i = 0; i < 18; i++) { diff --git a/src/tests/resolve.test.ts b/src/tests/resolve.test.ts index 6a3ef55..9dc28ee 100644 --- a/src/tests/resolve.test.ts +++ b/src/tests/resolve.test.ts @@ -1,5 +1,5 @@ import { buildModel } from "../model"; -import { resolveTarget, resolveHeading, ResolvedTarget } from "../resolve"; +import { resolveTarget, resolveHeading, resolveBlock, ResolvedTarget } from "../resolve"; import { headingPath } from "../projection"; const headingLevel = (r: ResolvedTarget | null): number | null => @@ -100,6 +100,26 @@ describe("resolveTarget dispatch", () => { } }); + test("a duplicate block id's bare form resolves only the first occurrence", () => { + const dupDoc = ["first ^dup", "", "second ^dup", ""].join("\n"); + const model = buildModel(dupDoc); + const r = resolveBlock(model, "dup"); + expect(r?.kind).toBe("block"); + if (r?.kind === "block") { + expect(r.block.content.start).toBe(dupDoc.indexOf("first")); + } + }); + + test("a duplicate block id's later occurrence resolves via its disambiguated address", () => { + const dupDoc = ["first ^dup", "", "second ^dup", ""].join("\n"); + const model = buildModel(dupDoc); + const r = resolveBlock(model, "dup\u{FC750}\u{F6440}"); + expect(r?.kind).toBe("block"); + if (r?.kind === "block") { + expect(r.block.content.start).toBe(dupDoc.indexOf("second")); + } + }); + test("resolves a frontmatter key", () => { const model = buildModel(doc); const r = resolveTarget(model, { From 8343a3c031cdc70165bc782063e9adf26d20e02e Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 09:06:53 -0500 Subject: [PATCH 48/73] Give every duplicate block id its own address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveBlock previously matched the first block in document order for a given id, and projectMap's blocks list pushed every block's raw id verbatim — so two blocks sharing an id showed up as the literal same string twice, with no way to address the second one. Same underlying bug as the duplicate-heading case, fixed the same way: the first occurrence keeps its bare id, and each later occurrence gets a suffix of the same reserved marker codepoints, via new allBlocksInOrder and disambiguatedBlockId (projection.ts) mirroring disambiguatedHeadingText. resolveBlock now recomputes every candidate's address and matches by plain string equality, exactly like resolveHeading. No buildModel-time collision guard is needed for blocks: a raw block id is constrained to [a-zA-Z0-9_-]+ by BLOCK_REFERENCE_REGEX, which cannot contain these (astral, non-ASCII) codepoints in the first place — this was already established when the heading guard was added. Co-Authored-By: Claude Sonnet 5 --- src/projection.ts | 68 +++++++++++++++++++++++++++++++++++++---------- src/resolve.ts | 23 +++++++--------- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/src/projection.ts b/src/projection.ts index 03bc6f5..7127904 100644 --- a/src/projection.ts +++ b/src/projection.ts @@ -1,4 +1,4 @@ -import { DocumentModel, SectionNode } from "./model.js"; +import { BlockNode, DocumentModel, SectionNode } from "./model.js"; import { DUPLICATE_DIGITS, DUPLICATE_MARKER } from "./constants.js"; /** @@ -60,6 +60,48 @@ export const disambiguatedHeadingText = (node: SectionNode): string => { : heading.text + DUPLICATE_MARKER + encodeOccurrenceSuffix(occurrence - 1); }; +/** + * Every block in the document, in document order (a pre-order walk of the + * section tree). Blocks are addressed globally by id, not scoped to a parent + * the way headings are scoped to sibling groups, so disambiguating a + * duplicate id needs this full document-order list rather than a single + * node's local neighbors. + */ +export const allBlocksInOrder = (root: SectionNode): BlockNode[] => { + const blocks: BlockNode[] = []; + const walk = (node: SectionNode): void => { + blocks.push(...node.blocks); + for (const child of node.children) { + walk(child); + } + }; + walk(root); + return blocks; +}; + +/** + * The id a block is keyed/addressed by: its raw id for the first occurrence + * among every block in the document sharing that id, or that id plus a + * reserved-codepoint suffix for each later occurrence (in document order). + * Mirrors {@link disambiguatedHeadingText}'s role for headings — both + * {@link projectMap} and the resolver derive from this one function, so an + * address is always matched by plain string equality, never decoded. A raw + * block id cannot itself collide with a synthesized suffix: block ids are + * constrained to `[a-zA-Z0-9_-]+` by {@link BLOCK_REFERENCE_REGEX} in + * model.ts, which cannot contain these (astral, non-ASCII) codepoints, so no + * buildModel-time guard is needed here the way there is for headings. + */ +export const disambiguatedBlockId = ( + block: BlockNode, + allBlocks: readonly BlockNode[] +): string => { + const sameId = allBlocks.filter((candidate) => candidate.id === block.id); + const occurrence = sameId.indexOf(block); + return occurrence <= 0 + ? block.id + : block.id + DUPLICATE_MARKER + encodeOccurrenceSuffix(occurrence - 1); +}; + /** * The terse, context-cheap public view of a document, derived from the * {@link DocumentModel}. It carries no in-band grammar: headings nest by @@ -72,7 +114,12 @@ export interface PublicMap { frontmatterFields: string[]; /** Headings nested by containment; see {@link HeadingTree}. */ headings: HeadingTree; - /** Block reference ids, bare (no `^`), in document order. */ + /** + * Block reference ids, bare (no `^`), in document order. A duplicate id + * gets its own entry per occurrence: the first keeps its bare id, and each + * later occurrence's entry has a {@link disambiguatedBlockId} suffix + * appended, so no two entries are ever the same string. + */ blocks: string[]; } @@ -100,19 +147,12 @@ export const projectMap = (model: DocumentModel): PublicMap => { // text invokes Object.prototype's `__proto__` setter instead, silently // discarding the heading. const headings: HeadingTree = Object.create(null) as HeadingTree; - const blocks: string[] = []; - // Blocks are addressed globally by bare id, so every block is listed in - // document order — including any under a heading shadowed by a duplicate. - const collectBlocks = (node: SectionNode): void => { - for (const block of node.blocks) { - blocks.push(block.id); - } - for (const child of node.children) { - collectBlocks(child); - } - }; - collectBlocks(model.root); + // Blocks are addressed globally by (possibly disambiguated) id, so every + // block is listed in document order — including any under a heading + // shadowed by a duplicate. + const allBlocks = allBlocksInOrder(model.root); + const blocks = allBlocks.map((block) => disambiguatedBlockId(block, allBlocks)); // Headings nest by containment. Every occurrence of a repeated sibling name // gets its own key via disambiguatedHeadingText, so — given buildModel's diff --git a/src/resolve.ts b/src/resolve.ts index 447436d..29d18f8 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -16,7 +16,7 @@ import { SectionNode, eachSection, } from "./model.js"; -import { headingPath } from "./projection.js"; +import { allBlocksInOrder, disambiguatedBlockId, headingPath } from "./projection.js"; import { HeadingAddress, TargetType } from "./instructions.js"; export type ResolvedTarget = @@ -58,22 +58,19 @@ export const resolveHeading = ( return match ? { kind: "heading", section: match } : null; }; -/** Resolve a bare block id to its block node, or `null`. */ +/** + * Resolve a block address to its block node, or `null`. Mirrors + * resolveHeading: a duplicate id's later occurrence is disambiguated (see + * {@link disambiguatedBlockId}), so recomputing every block's address and + * matching by plain string equality resolves at most one candidate. + */ export const resolveBlock = ( model: DocumentModel, id: string ): { kind: "block"; block: BlockNode } | null => { - let found: BlockNode | null = null; - eachSection(model.root, (node) => { - if (found) { - return; - } - const block = node.blocks.find((candidate) => candidate.id === id); - if (block) { - found = block; - } - }); - return found ? { kind: "block", block: found } : null; + const blocks = allBlocksInOrder(model.root); + const match = blocks.find((block) => disambiguatedBlockId(block, blocks) === id); + return match ? { kind: "block", block: match } : null; }; /** Resolve a frontmatter key to its entry, or `null`. */ From 5ea9d052c4ee30d4f4d169b864d4632d5759ebbf Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 09:12:47 -0500 Subject: [PATCH 49/73] Allow a disambiguated block target through schema validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block-id disambiguation work only touched resolution and map projection; it missed that InstructionInputObjectSchema's target validation independently enforces [A-Za-z0-9_-]+ on a block target, which rejected the very marker-suffixed addresses just introduced. Caught by an obsidian-local-rest-api integration test exercising a real PATCH against a disambiguated block target. BLOCK_TARGET_PATTERN extends the existing BLOCK_ID_PATTERN with an optional reserved-marker suffix, but only for `target` (addressing an existing block) — `content` on a marker-scope rename (naming a *new* real block id) keeps the original strict pattern, since renaming a block to a disambiguated-looking id is never valid. Co-Authored-By: Claude Sonnet 5 --- src/schema.ts | 17 ++++++++++++++++- src/tests/schema.test.ts | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/schema.ts b/src/schema.ts index c222a7c..932c79f 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -32,6 +32,7 @@ import { TargetType, isValidCell, } from "./instructions.js"; +import { DUPLICATE_DIGITS, DUPLICATE_MARKER } from "./constants.js"; // --- Field pieces -------------------------------------------------------- @@ -191,6 +192,20 @@ const carriers = ["content", "value", "destination"] as const; */ const BLOCK_ID_PATTERN = /^[A-Za-z0-9_-]+$/; +/** + * A block *target* additionally accepts the disambiguated form a duplicate + * block id's later occurrence is addressed by (see disambiguatedBlockId in + * projection.ts): the raw id followed by the reserved marker and one or more + * digit codepoints. Only `target` (addressing an existing block) accepts + * this — `content` on a `marker`-scope rename (naming a *new* real block id) + * still requires {@link BLOCK_ID_PATTERN} alone, since a disambiguated + * address is never something you rename a block to. + */ +const BLOCK_TARGET_PATTERN = new RegExp( + `^[A-Za-z0-9_-]+(${DUPLICATE_MARKER}[${DUPLICATE_DIGITS.join("")}]+)?$`, + "u" +); + /** A 2-D array of strings: table rows for a `block` `content`-cell `value` write. */ const isTableRowValue = (value: unknown): value is string[][] => Array.isArray(value) && @@ -226,7 +241,7 @@ const instructionAlgebra = ( path: ["target"], message: `a ${targetType} target must be a string`, }); - } else if (targetType === "block" && !BLOCK_ID_PATTERN.test(target)) { + } else if (targetType === "block" && !BLOCK_TARGET_PATTERN.test(target)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["target"], diff --git a/src/tests/schema.test.ts b/src/tests/schema.test.ts index 54f96d6..036ed0d 100644 --- a/src/tests/schema.test.ts +++ b/src/tests/schema.test.ts @@ -65,6 +65,15 @@ const valid: { name: string; instruction: InstructionInput }[] = [ name: "block table-row write", instruction: { targetType: "block", target: "abc", operation: "append", value: [["a", "b"]] }, }, + { + name: "block target disambiguated by the duplicate-marker suffix", + instruction: { + targetType: "block", + target: "abc\u{FC750}\u{F6440}", + operation: "replace", + content: "x", + }, + }, { name: "frontmatter value write", instruction: { targetType: "frontmatter", target: "title", operation: "replace", value: "T" }, @@ -174,6 +183,16 @@ describe("InstructionInputSchema", () => { name: "a block marker rename to an id containing a space", instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", content: "new id" }, }, + { + name: "a block marker rename to an id carrying the duplicate-marker suffix", + instruction: { + targetType: "block", + target: "abc", + operation: "replace", + scope: "marker", + content: "def\u{FC750}\u{F6440}", + }, + }, { name: "a block marker rename to an id containing a caret", instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", content: "^def" }, From 053e86e614b0e446d25acf018b2bfa4afd64eae8 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 09:14:36 -0500 Subject: [PATCH 50/73] Document the duplicate-block-id marker suffix in the target field description Mirrors the note already added for duplicate headings: a duplicate block id's later occurrence carries the same reserved marker suffix, to be copied verbatim rather than typed by hand. Co-Authored-By: Claude Sonnet 5 --- src/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schema.ts b/src/schema.ts index 932c79f..497bb47 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -102,7 +102,7 @@ export const InstructionInputObjectSchema = z target: z .union([z.array(z.string()), z.string(), z.null()]) .describe( - "The node to edit. For a heading: an array of heading texts from the top level down to the target (e.g. [\"Overview\",\"Details\"]), or null/[] for the document root. If a heading is a duplicate of an earlier sibling under the same parent, its address carries an extra non-printable marker suffix appended by the server — copy that address verbatim from wherever the document's heading structure was discovered, never retype or reconstruct it. For a block: the bare block id, without the leading `^` (letters, numbers, hyphens, and underscores only). For a frontmatter field: the key." + "The node to edit. For a heading: an array of heading texts from the top level down to the target (e.g. [\"Overview\",\"Details\"]), or null/[] for the document root. If a heading is a duplicate of an earlier sibling under the same parent, its address carries an extra non-printable marker suffix appended by the server — copy that address verbatim from wherever the document's heading structure was discovered, never retype or reconstruct it. For a block: the bare block id, without the leading `^` (letters, numbers, hyphens, and underscores only) — a duplicate block id's later occurrence carries the same kind of marker suffix, copied verbatim the same way. For a frontmatter field: the key." ), operation: z .enum(operationValues) From e0c00b5015a8cf84c6a9e7e719430b3d72c0eb01 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 17:14:18 -0500 Subject: [PATCH 51/73] Add failing tests for unescaped table cell content A table-row write formats each cell as "| " + cells.join(" | ") + " |" with no escaping, so cell content containing the table's own delimiters corrupts the table it is written into: - A cell containing `|` silently becomes two cells, shifting every column after it and leaving a row whose cell count no longer matches the header. - A cell containing a line break splits one row into two malformed ones. Neither is caught by the column-count check, which counts entries in the supplied array rather than cells in the rendered row. The array form exists precisely so a caller supplies content and the library owns the table syntax, so both belong to the library to handle. Co-Authored-By: Claude Fable 5 --- src/tests/engine.test.ts | 52 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/tests/engine.test.ts b/src/tests/engine.test.ts index 925908a..7caf2c9 100644 --- a/src/tests/engine.test.ts +++ b/src/tests/engine.test.ts @@ -6,7 +6,12 @@ import { Instruction, } from "../instructions"; import { RootHasNoMarkerError } from "../ranges"; -import { NotATableError, TableColumnCountError } from "../engine/table"; +import { buildModel } from "../model"; +import { + NotATableError, + TableColumnCountError, + InvalidCellContentError, +} from "../engine/table"; // A small tree: A (h1) > B (h2), then C (h1), each with a one-line body and a // library-owned blank-line gap between siblings. @@ -326,6 +331,51 @@ describe("patch — block table-row cells", () => { ); }); + test("a pipe in a cell is escaped so it stays one cell", () => { + // The array form exists so a caller supplies cell *content* and the library + // renders the table syntax. An unescaped `|` would silently split the cell + // and shift every column after it. + const result = patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + value: [["Seattle | Tacoma", "16"]], + }); + expect(result.document).toContain("| Seattle \\| Tacoma | 16 |"); + + // And it survives a round trip: re-parsing the patched table still sees two + // columns, with the pipe restored as cell text. + const block = buildModel(result.document).root.blocks[0]; + expect(block.columns).toHaveLength(2); + }); + + test("a newline in a cell is rejected rather than splitting the row", () => { + // A line break cannot be expressed inside a GFM table cell; writing it + // verbatim would break one row into two malformed ones. + expect(() => + patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + value: [["two\nlines", "16"]], + }) + ).toThrow(InvalidCellContentError); + }); + + test("a carriage return in a cell is rejected too", () => { + expect(() => + patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + value: [["two\r\nlines", "16"]], + }) + ).toThrow(InvalidCellContentError); + }); + test("a row with the wrong number of cells raises TableColumnCountError", () => { expect(() => patch(TABLE_DOC, { From f81a92331cb9737f1637d130b360f0a0ec31f340 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Wed, 22 Jul 2026 17:15:35 -0500 Subject: [PATCH 52/73] Escape pipes and reject line breaks in table cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A table-row write rendered each cell verbatim between the delimiters it was building, so content carrying those delimiters corrupted the table. An unescaped `|` ended its cell early, shifting every column after it and leaving a row whose real cell count no longer matched the header — past the column-count check, which counts entries in the supplied array rather than cells in the rendered row. Cells are now escaped as content: `|` becomes `\|`, the one escape GFM defines inside a table row. Backslashes are deliberately left alone — cell text is still markdown, and doubling them would rewrite a caller's `\*` or link syntax. The trade-off is that a literal backslash directly before a pipe is inexpressible, which is rarer than markdown in a cell. A line break has no escape at all, since a row is one line by definition, so it now raises InvalidCellContentError rather than being written and splitting the row. Turning it into a `
` would invent markup the caller did not ask for. Co-Authored-By: Claude Fable 5 --- src/engine/table.ts | 33 ++++++++++++++++++++++++++++++++- src/index.ts | 6 +++++- src/schema.ts | 2 +- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/engine/table.ts b/src/engine/table.ts index 1a10719..fd48f99 100644 --- a/src/engine/table.ts +++ b/src/engine/table.ts @@ -20,6 +20,9 @@ export class NotATableError extends EngineError {} /** A row's cell count doesn't match the table's column count. */ export class TableColumnCountError extends EngineError {} +/** A cell's text cannot be represented inside a table row. */ +export class InvalidCellContentError extends EngineError {} + interface ParsedTable { header: string; separator: string; @@ -38,7 +41,35 @@ const parseTable = (text: string): ParsedTable => { return { header: lines[0] ?? "", separator: lines[1] ?? "", rows: lines.slice(2), trailingEol }; }; -const formatRow = (row: string[]): string => "| " + row.join(" | ") + " |"; +/** + * Render one cell's text as table-row source. A caller supplies cell + * *content* — that is the point of the array form — so the delimiters of the + * surrounding syntax are ours to escape, not theirs to remember. + * + * `|` is escaped to `\|`, the only escape GFM defines inside a table row; + * left alone it would end the cell early and shift every column after it, + * which the column-count check cannot catch because it counts entries in the + * supplied array, not cells in the rendered row. A backslash is *not* + * escaped: cell content is still markdown (a caller may legitimately write + * `\*` or a link), and doubling backslashes would rewrite that markdown. The + * cost is that a cell wanting a literal backslash immediately before a pipe + * cannot express it; that is rarer than writing markdown in a cell. + * + * A line break has no escape at all — a table row is one line by definition — + * so it is rejected rather than silently written, split, or turned into a + * `
` the caller never asked for. + */ +const formatCell = (cell: string): string => { + if (/[\r\n]/.test(cell)) { + throw new InvalidCellContentError( + `cell ${JSON.stringify(cell)} contains a line break, which cannot appear inside a table row` + ); + } + return cell.replace(/\|/g, "\\|"); +}; + +const formatRow = (row: string[]): string => + "| " + row.map(formatCell).join(" | ") + " |"; export const patchTableRows = ( document: string, diff --git a/src/index.ts b/src/index.ts index e15d642..305243f 100755 --- a/src/index.ts +++ b/src/index.ts @@ -40,7 +40,11 @@ export { InstructionInputSchema, InstructionInputObjectSchema, } from "./schema.js"; -export { NotATableError, TableColumnCountError } from "./engine/table.js"; +export { + NotATableError, + TableColumnCountError, + InvalidCellContentError, +} from "./engine/table.js"; export type { Instruction, InstructionInput, diff --git a/src/schema.ts b/src/schema.ts index 497bb47..06bbae1 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -124,7 +124,7 @@ export const InstructionInputObjectSchema = z value: z .unknown() .describe( - "Structured JSON payload: a frontmatter value (any JSON — string, number, boolean, array, object, null; for `prepend`/`append` this merges: list concat, dict merge, string concat), or table rows on a `block` target's `content` cell (a 2-D array of strings, one row per entry — `replace` swaps the body rows, `prepend`/`append` insert before/after the existing ones; each row's length must match the table's column count). Provide exactly one of `content`, `value`, or `destination`." + "Structured JSON payload: a frontmatter value (any JSON — string, number, boolean, array, object, null; for `prepend`/`append` this merges: list concat, dict merge, string concat), or table rows on a `block` target's `content` cell (a 2-D array of strings, one row per entry — `replace` swaps the body rows, `prepend`/`append` insert before/after the existing ones; each row's length must match the table's column count). Table cells are content, not table source: a `|` is escaped for you, and a cell containing a line break is rejected, since a row is a single line. Provide exactly one of `content`, `value`, or `destination`." ) .optional(), destination: destination.optional(), From 629227e3cabd1e74658abc5a2ab242cbc6f1afb9 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 07:14:40 -0500 Subject: [PATCH 53/73] Remove the 1.x engine and port the CLI to the 2.0 engine The 1.x API (applyPatch, getDocumentMap, the PatchInstruction types, and the chalk-based map printer) is gone; 2.0 is a clean break, and callers who need the old behavior can stay on markdown-patch@1. The mdpatch CLI now drives the 2.0 engine: `patch` gains delete/scope/ifMatch/ create-target-if-missing flags, `apply` takes 2.0 instruction JSON, `query` reads through readTarget, and `print-map` emits the projected public map (JSON, or filtered addresses with a regex). typeGuards.ts shrinks to the guards the frontmatter cells actually use, and chalk and the deprecated @types/commander stub are dropped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015Y4vyHDvFCMPe2VRVp8H7B --- README.md | 29 +- package-lock.json | 23 - package.json | 2 - pages/overview.md | 2 +- src/cli.ts | 294 ++++++--- src/debug.ts | 89 --- src/index.ts | 13 +- src/map.ts | 270 -------- src/patch.ts | 777 ---------------------- src/tests/map.test.ts | 279 -------- src/tests/patch.test.ts | 1395 --------------------------------------- src/typeGuards.ts | 37 +- src/types.ts | 300 +-------- 13 files changed, 248 insertions(+), 3262 deletions(-) delete mode 100644 src/debug.ts delete mode 100644 src/map.ts delete mode 100644 src/patch.ts delete mode 100644 src/tests/map.test.ts delete mode 100644 src/tests/patch.test.ts diff --git a/README.md b/README.md index 286d86e..6a88b2c 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ patch(document, { }); ``` -Do **not** include `#` characters here. They are not stripped — they become part of the heading text, so `"## Follow-ups"` renames the heading to the literal `## Follow-ups`. (The deprecated `applyPatch` required them; if you are migrating, drop them.) +Do **not** include `#` characters here. They are not stripped — they become part of the heading text, so `"## Follow-ups"` renames the heading to the literal `## Follow-ups`. (The removed 1.x `applyPatch` required them; if you are migrating, drop them.) The same shape renames a block id (`targetType: "block"`, new id without `^`) or a frontmatter key (`targetType: "frontmatter"`, new key in `content`). @@ -211,19 +211,19 @@ All failures extend `EngineError`: ## CLI reference -> **Note:** the `mdpatch` CLI currently drives the deprecated 1.x engine described under [Deprecated: the 1.x API](#deprecated-the-1x-api). Its addressing is `::`-joined rather than an array, and it has no access to `delete`, moves, or `ifMatch`. CLI support for the model above is still to come; use the library for anything the 1.x surface cannot express. +The CLI drives the same engine as the library: `patch` is the quick flag-based form for common single edits, and `apply` takes full instruction JSON for everything the model can express (moves, table rows, `ifMatch` pipelines). ### `mdpatch patch` -Apply a single patch operation. +Apply a single instruction built from flags. Content is read from stdin unless `--input` is given; `delete` takes no content. ``` mdpatch patch [options] ``` -- `` — `append`, `prepend`, or `replace` +- `` — `append`, `prepend`, `replace`, or `delete` - `` — `heading`, `block`, or `frontmatter` -- `` — the target address, `::`-joined for nested headings +- `` — the target address: a `::`-joined containment path for headings (`""` for the document root), a bare block id, or a frontmatter key - `` — file to modify (patched in-place by default) Options: @@ -233,10 +233,17 @@ Options: | `-i, --input ` | Read content from a file instead of stdin | | `-o, --output ` | Write result to a file instead of patching in-place; use `-` for stdout | | `-d, --delimiter ` | Heading path delimiter (default: `::`) | +| `-s, --scope ` | `content` (default), `marker`, `markerAndContent`, or `parent` | +| `--if-match ` | Fail unless the document's version token matches (see `print-map`) | +| `--create-target-if-missing` | Create the target (and missing ancestors) when it does not exist | +| `--reject-if-content-preexists` | Fail instead of applying when the content is already present | + +For a `frontmatter` target the payload is parsed as JSON, falling back to the raw string; for `--scope parent` (a move) the payload is the JSON `destination`, e.g. `{"parent": ["Archive"], "place": "last"}`. ```sh echo "- Send the report" | mdpatch patch append heading "Meeting Notes::Action Items" notes.md -echo '"done"' | mdpatch patch replace frontmatter status notes.md +echo '["draft", "urgent"]' | mdpatch patch replace frontmatter tags notes.md +mdpatch patch delete block quote-1 notes.md -s markerAndContent ``` ### `mdpatch apply` @@ -247,11 +254,11 @@ Apply one or more patch instructions from a JSON patch file. mdpatch apply [options] ``` -The patch file should be a JSON object (single instruction) or JSON array (multiple instructions). Use `-` to read from stdin. +The patch file should be a JSON object (single instruction) or JSON array (multiple instructions, applied in order) in exactly the shape the library's `patch` accepts — see [The model](#the-model). Use `-` to read from stdin. ### `mdpatch query` -Extract the content of a specific target and write it to stdout (or a file). +Read a target's content and write it to stdout (or a file with `-o`): markdown for headings and blocks, JSON for frontmatter values. ``` mdpatch query [options] @@ -259,15 +266,15 @@ mdpatch query [options] ### `mdpatch print-map` -Show all patchable targets discovered in a document, useful for finding the right target address. +Show a document's addressable map — its `version` token (for `--if-match`), frontmatter fields, heading tree, and block ids — as JSON. With a regex, list only matching addresses, one `typeaddress` per line. ``` mdpatch print-map [regex] ``` -## Deprecated: the 1.x API +## Migrating from 1.x -`applyPatch` and `getDocumentMap` are the previous generation of this library. They still work and are still exported, but they are deprecated and will be removed in a future major release. +Version 2.0 removes the 1.x API: `applyPatch`, `getDocumentMap`, and the `PatchInstruction` types are gone. If you need the old behavior as-is, stay on `markdown-patch@1`. The 1.x API spread its addressing across a `::`-joined `target` string with a separate `targetDelimiter`, offered no `delete` operation, no moves, and no `version` token. To migrate, switch to `patch` and move each field across: diff --git a/package-lock.json b/package-lock.json index a4532bb..f9401dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "ISC", "dependencies": { "@tsconfig/node16": "^16.1.3", - "chalk": "^5.3.0", "commander": "^12.1.0", "marked": "^17.0.1", "yaml": "^2.5.1", @@ -20,7 +19,6 @@ "mdpatch": "dist/cli.js" }, "devDependencies": { - "@types/commander": "^2.12.2", "@types/jest": "^29.5.12", "@types/node": "^22.4.0", "http-server": "^14.1.1", @@ -1203,16 +1201,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/commander": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@types/commander/-/commander-2.12.2.tgz", - "integrity": "sha512-0QEFiR8ljcHp9bAbWxecjVRuAMr16ivPiGOw6KFQBVrVd0RQIcM3xKdRisH2EDWgVWujiYtHwhSkSUoAAGzH7Q==", - "deprecated": "This is a stub types definition for commander (https://github.com/tj/commander.js). commander provides its own type definitions, so you don't need @types/commander installed!", - "dev": true, - "dependencies": { - "commander": "*" - } - }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1680,17 +1668,6 @@ } ] }, - "node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", diff --git a/package.json b/package.json index 1452a7e..329d841 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,12 @@ { "dependencies": { "@tsconfig/node16": "^16.1.3", - "chalk": "^5.3.0", "commander": "^12.1.0", "marked": "^17.0.1", "yaml": "^2.5.1", "zod": "3.25.76" }, "devDependencies": { - "@types/commander": "^2.12.2", "@types/jest": "^29.5.12", "@types/node": "^22.4.0", "http-server": "^14.1.1", diff --git a/pages/overview.md b/pages/overview.md index 965981e..40274e3 100644 --- a/pages/overview.md +++ b/pages/overview.md @@ -72,4 +72,4 @@ The leading `\n` in the content above is deliberate. Content is spliced in exact See {@link Reference.patch} for the full instruction shape, {@link Reference.readTarget} for the read-side mirror of the same addressing, and {@link Reference.projectMap} for discovering what a document has to target. -> **Note:** {@link Reference.applyPatch} and {@link Reference.getDocumentMap} are the deprecated 1.x API. They still work, but new code should use {@link Reference.patch}; see the README for the migration table. +> **Note:** the 1.x API (`applyPatch` and `getDocumentMap`) was removed in 2.0; see the README's "Migrating from 1.x" section for the migration table. diff --git a/src/cli.ts b/src/cli.ts index 7a3609e..0c6957d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,16 @@ #!/usr/bin/env node import { Command } from "commander"; import fs from "fs/promises"; -import { getDocumentMap } from "./map.js"; -import { printMap } from "./debug.js"; -import { PatchInstruction, PatchOperation, PatchTargetType } from "./types.js"; -import { applyPatch } from "./patch.js"; +import { patch } from "./engine.js"; +import { buildModel } from "./model.js"; +import { projectMap, headingTreePaths } from "./projection.js"; +import { readTarget } from "./read.js"; +import { + EngineError, + InstructionInput, + PatchResult, + TargetType, +} from "./instructions.js"; import packageJson from "../package.json"; async function readStdin(): Promise { @@ -16,9 +22,47 @@ async function readStdin(): Promise { }); } +/** Print engine failures as one clean line rather than a stack trace. */ +function fail(e: unknown): never { + if (e instanceof EngineError) { + console.error(`${e.constructor.name}: ${e.message}`); + process.exit(1); + } + throw e; +} + +function printWarnings(result: PatchResult): void { + for (const warning of result.warnings) { + console.error(`warning (${warning.code}): ${warning.message}`); + } +} + +async function writeResult( + document: string, + output: string | undefined, + fallbackPath: string +): Promise { + if (output === "-") { + process.stdout.write(document); + } else { + await fs.writeFile(output ? output : fallbackPath, document); + } +} + +/** A heading target is a delimiter-joined containment path; `''` is the root. */ +function parseTarget( + targetType: TargetType, + target: string, + delimiter: string +): string | string[] | null { + if (targetType !== "heading") { + return target; + } + return target === "" ? null : target.split(delimiter); +} + const program = new Command(); -// Configure the CLI program .name(Object.keys(packageJson.bin)[0]) .description(packageJson.description) @@ -26,20 +70,55 @@ program program .command("print-map") - .argument("", "filepath to show identified patchable paths for") + .description( + "Print a document's addressable map: its version token, frontmatter " + + "fields, heading tree, and block ids." + ) + .argument("", "filepath to show identified patchable targets for") .argument( "[regex]", - "limit displayed matches to those matching the supplied regular expression" + "list only addresses matching the supplied regular expression, one per line" ) - .action(async (path: string, regex: string | undefined) => { + .option( + "-d, --delimiter ", + "Heading delimiter to use in place of '::'.", + "::" + ) + .action(async (path: string, regex: string | undefined, options) => { const document = await fs.readFile(path, "utf-8"); - const documentMap = getDocumentMap(document); + const map = projectMap(buildModel(document)); + + if (regex === undefined) { + console.log(JSON.stringify(map, null, 2)); + return; + } - printMap(document, documentMap, regex ? new RegExp(regex) : undefined); + const pattern = new RegExp(regex); + for (const headingPath of headingTreePaths(map.headings)) { + const joined = headingPath.join(options.delimiter); + if (pattern.test(joined)) { + console.log(`heading\t${joined}`); + } + } + for (const blockId of map.blocks) { + if (pattern.test(blockId)) { + console.log(`block\t${blockId}`); + } + } + for (const field of map.frontmatterFields) { + if (pattern.test(field)) { + console.log(`frontmatter\t${field}`); + } + } }); program .command("patch") + .description( + "Apply a single instruction built from flags; content is read from " + + "stdin unless --input is given. For the full instruction model " + + "(table rows, moves), use `apply`." + ) .option( "-i, --input ", "Path to content to insert; by default reads from stdin." @@ -53,132 +132,191 @@ program "Heading delimiter to use in place of '::'.", "::" ) - .argument("", "Operation to perform ('replace', 'append', etc.)") - .argument("", "Target type ('heading', 'block', etc.)") + .option( + "-s, --scope ", + "Scope to operate on ('content', 'marker', 'markerAndContent', 'parent'); defaults to 'content'." + ) + .option( + "--if-match ", + "Fail unless the document's version token matches (see `print-map`)." + ) + .option( + "--create-target-if-missing", + "Create the target (and any missing ancestors) when it does not exist." + ) + .option( + "--reject-if-content-preexists", + "Fail instead of applying when the content already appears in the target." + ) + .argument( + "", + "Operation to perform ('replace', 'prepend', 'append', 'delete')" + ) + .argument( + "", + "Target type ('heading', 'block', 'frontmatter')" + ) .argument( "", - "Target ('::'-delimited by default for Headings); see `mdpatch print-map ` for options)" + "Target ('::'-delimited containment path for headings, '' for the " + + "document root; a bare block id; a frontmatter key); see `mdpatch " + + "print-map ` for options" ) .argument("", "Path to document to apply patch to.") .action( async ( - operation: PatchOperation, - targetType: PatchTargetType, + operation: string, + targetType: string, target: string, documentPath: string, options ) => { - let content: string; - if (options.input) { - content = await fs.readFile(options.input, "utf-8"); - } else { - content = await readStdin(); + const instruction: Record = { + operation, + targetType, + target: parseTarget(targetType as TargetType, target, options.delimiter), + }; + if (options.scope !== undefined) { + instruction.scope = options.scope; + } + if (options.ifMatch !== undefined) { + instruction.ifMatch = options.ifMatch; + } + if (options.createTargetIfMissing) { + instruction.createTargetIfMissing = true; + } + if (options.rejectIfContentPreexists) { + instruction.rejectIfContentPreexists = true; } - const document = await fs.readFile(documentPath, "utf-8"); + // Delete carries no payload; everything else reads one from stdin/-i. + if (operation !== "delete") { + const raw = options.input + ? await fs.readFile(options.input, "utf-8") + : await readStdin(); + if (options.scope === "parent") { + // A move's payload is its JSON destination: {"parent": [...], "place": ...} + instruction.destination = JSON.parse(raw); + } else if (targetType === "frontmatter" && options.scope !== "marker") { + // Frontmatter values are structured JSON; fall back to the raw + // string so `echo done | mdpatch patch replace frontmatter status` + // does what it looks like. + try { + instruction.value = JSON.parse(raw); + } catch { + instruction.value = raw.replace(/\n$/, ""); + } + } else { + instruction.content = raw; + } + } - const instruction = { - operation, - targetType, - content, - target: - targetType !== "heading" ? target : target.split(options.delimiter), - } as PatchInstruction; - - const patchedDocument = applyPatch(document, instruction); - if (options.output === "-") { - process.stdout.write(patchedDocument); - } else { - await fs.writeFile( - options.output ? options.output : documentPath, - patchedDocument - ); + const document = await fs.readFile(documentPath, "utf-8"); + let result: PatchResult; + try { + result = patch(document, instruction as InstructionInput); + } catch (e) { + fail(e); } + printWarnings(result); + await writeResult(result.document, options.output, documentPath); } ); program .command("apply") + .description( + "Apply a JSON patch file: a single instruction object or an array of " + + "them, applied in order." + ) .argument("", "file to patch") - .argument("", "patch file to apply") + .argument("", "patch file to apply; use '-' for stdin") .option( "-o, --output ", "write output to the specified path instead of applying in-place; use '-' for stdout" ) - .action(async (path: string, patch: string, options) => { - let patchParsed: PatchInstruction[]; + .action(async (path: string, patchPath: string, options) => { let patchData: string; try { - if (patch === "-") { - patchData = await readStdin(); - } else { - patchData = await fs.readFile(patch, "utf-8"); - } + patchData = + patchPath === "-" + ? await readStdin() + : await fs.readFile(patchPath, "utf-8"); } catch (e) { console.error("Failed to read patch: ", e); process.exit(1); } + let instructions: InstructionInput[]; try { - const parsedData = JSON.parse(patchData); - if (!Array.isArray(parsedData)) { - patchParsed = [parsedData]; - } else { - patchParsed = parsedData; - } + const parsed: unknown = JSON.parse(patchData); + // Each instruction is schema-validated by `patch` at the boundary; the + // only shape enforced here is object-or-array-of-objects. + instructions = (Array.isArray(parsed) + ? parsed + : [parsed]) as InstructionInput[]; } catch (e) { console.error("Could not parse patch file as JSON"); process.exit(1); } let document = await fs.readFile(path, "utf-8"); - console.log("Document", document); - for (const instruction of patchParsed) { - document = applyPatch(document, instruction); + try { + for (const instruction of instructions) { + const result = patch(document, instruction); + printWarnings(result); + document = result.document; + } + } catch (e) { + fail(e); } - if (options.output === "-") { - process.stdout.write(document); - } else { - await fs.writeFile(options.output ? options.output : path, document); - } + await writeResult(document, options.output, path); }); program .command("query") + .description( + "Read a target's content: markdown for headings and blocks, JSON for " + + "frontmatter values." + ) .option( "-o, --output ", - "Path to write output to; use '-' for stdout. Defaults to patching in-place." + "Path to write output to; defaults to stdout." ) .option( "-d, --delimiter ", "Heading delimiter to use in place of '::'.", "::" ) - .argument("", "Target type ('heading', 'block', etc.)") + .argument( + "", + "Target type ('heading', 'block', 'frontmatter')" + ) .argument( "", - "Target ('::'-delimited by default for Headings); see `mdpatch print-map ` for options)" + "Target ('::'-delimited containment path for headings, '' for the " + + "document root; a bare block id; a frontmatter key); see `mdpatch " + + "print-map ` for options" ) .argument("", "Path to document to query from.") .action( - async ( - targetType: PatchTargetType, - target: string, - documentPath: string, - options - ) => { + async (targetType: string, target: string, documentPath: string, options) => { const document = await fs.readFile(documentPath, "utf-8"); - const documentMap = getDocumentMap(document); - const actualTarget = - targetType !== "heading" - ? target - : target.split(options.delimiter).join("\u001f"); - - const value = document.slice( - documentMap[targetType][actualTarget].content.start, - documentMap[targetType][actualTarget].content.end - ); + let result; + try { + result = readTarget(document, { + targetType, + target: parseTarget(targetType as TargetType, target, options.delimiter), + } as Parameters[1]); + } catch (e) { + fail(e); + } + + const value = + result.kind === "frontmatter" + ? JSON.stringify(result.value, null, 2) + : result.content; if (options.output) { await fs.writeFile(options.output, value); diff --git a/src/debug.ts b/src/debug.ts deleted file mode 100644 index d8d8855..0000000 --- a/src/debug.ts +++ /dev/null @@ -1,89 +0,0 @@ -import chalk from "chalk"; -import { DocumentMap } from "./types.js"; - -export const printMap = ( - content: string, - documentMap: DocumentMap, - regex: RegExp | undefined -): void => { - for (const frontmatterField in documentMap.frontmatter) { - const blockName = `[${chalk.magenta("frontmatter")}] ${chalk.blueBright(frontmatterField)}`; - console.log("\n" + blockName + "\n"); - console.log(JSON.stringify(documentMap.frontmatter[frontmatterField])); - } - - const targetablePositions = { - heading: documentMap.heading, - block: documentMap.block, - }; - for (const type in targetablePositions) { - for (const positionName in targetablePositions[ - type as keyof typeof targetablePositions - ]) { - const position = - targetablePositions[type as keyof typeof targetablePositions][ - positionName - ]; - - const blockName = `[${chalk.magenta(type)}] ${positionName - .split("\u001f") - .map((pos) => chalk.blueBright(pos)) - .join(",")}`; - if (regex && !blockName.match(regex)) { - continue; - } - console.log("\n" + blockName + "\n"); - if (position.content.start < position.marker.start) { - console.log( - content - .slice(position.content.start - 100, position.content.start) - .replaceAll("\n", "\\n\n") + - chalk.black.bgGreen( - content - .slice(position.content.start, position.content.end) - .replaceAll("\n", "\\n\n") - ) + - content - .slice( - position.content.end, - Math.min(position.content.end + 100, position.marker.start) - ) - .replaceAll("\n", "\\n\n") + - chalk.black.bgRed( - content - .slice(position.marker.start, position.marker.end) - .replaceAll("\n", "\\n\n") - ) + - content - .slice(position.marker.end, position.marker.end + 100) - .replaceAll("\n", "\\n\n") - ); - } else { - console.log( - content - .slice(position.marker.start - 100, position.marker.start) - .replaceAll("\n", "\\n\n") + - chalk.black.bgRed( - content - .slice(position.marker.start, position.marker.end) - .replaceAll("\n", "\\n\n") - ) + - content - .slice( - position.marker.end, - Math.min(position.marker.end + 100, position.content.start) - ) - .replaceAll("\n", "\\n\n") + - chalk.black.bgGreen( - content - .slice(position.content.start, position.content.end) - .replaceAll("\n", "\\n\n") - ) + - content - .slice(position.content.end, position.content.end + 100) - .replaceAll("\n", "\\n\n") - ); - } - } - } -}; diff --git a/src/index.ts b/src/index.ts index 305243f..f2f8d7b 100755 --- a/src/index.ts +++ b/src/index.ts @@ -2,18 +2,7 @@ * @module Reference */ -export { - PatchFailureReason, - PatchFailed, - PatchError, - TablePartsNotFound, - applyPatch, -} from "./patch.js"; -export { getDocumentMap } from "./map.js"; - -export * from "./types.js"; - -// --- 2.0 engine ---------------------------------------------------------- +export type { DocumentRange } from "./types.js"; export { patch } from "./engine.js"; export { buildModel } from "./model.js"; diff --git a/src/map.ts b/src/map.ts deleted file mode 100644 index 1af2578..0000000 --- a/src/map.ts +++ /dev/null @@ -1,270 +0,0 @@ -import * as marked from "marked"; -import { parse as parseYaml } from "yaml"; - -import { - DocumentMap, - DocumentMapMarkerContentPair, - HeadingMarkerContentPair, - PreprocessedDocument, -} from "./types.js"; - -import { - CAN_INCLUDE_BLOCK_REFERENCE, - TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE, -} from "./constants.js"; -import { FrontmatterParseError } from "./instructions.js"; - -export { FrontmatterParseError }; - -function getHeadingPositions( - document: string, - tokens: marked.TokensList, - contentOffset: number -): Record { - const positions: Record = { - "": { - content: { - start: contentOffset, - end: document.length + contentOffset, - }, - marker: { - start: 0, - end: 0, - }, - level: 0, - }, - }; - const stack: Array<{ heading: string; position: HeadingMarkerContentPair }> = - []; - - // Pre-compute the byte offset of every top-level token by accumulating raw - // lengths. marked's block tokens concatenate back to the original input, so - // this gives exact positions without any indexOf search — avoiding false - // matches inside code spans, table cells, fenced code blocks, etc. - const tokenOffsets: number[] = []; - let runningOffset = 0; - for (const token of tokens) { - tokenOffsets.push(runningOffset); - runningOffset += token.raw.length; - } - - tokens.forEach((token, index) => { - if (token.type === "heading") { - const headingToken = token as marked.Tokens.Heading; - - const startHeading = tokenOffsets[index]; - const endHeading = startHeading + headingToken.raw.trimEnd().length + 1; - const headingLevel = headingToken.depth; - - // Determine the start of the content after this heading - const startContent = endHeading; - - // Determine the end of the content before the next heading of the same or higher level, or end of document - let endContent: number | undefined = undefined; - for (let i = index + 1; i < tokens.length; i++) { - if ( - tokens[i].type === "heading" && - (tokens[i] as marked.Tokens.Heading).depth <= headingLevel - ) { - endContent = tokenOffsets[i]; - break; - } - } - if (endContent === undefined) { - endContent = document.length; - } - - const currentHeading: HeadingMarkerContentPair = { - content: { - start: startContent + contentOffset, - end: endContent + contentOffset, - }, - marker: { - start: startHeading + contentOffset, - end: endHeading + contentOffset, - }, - level: headingLevel, - }; - - // Build the full heading path with parent headings separated by \u001f - let fullHeadingPath = headingToken.text.trim(); - while ( - stack.length && - stack[stack.length - 1].position.level >= headingLevel - ) { - stack.pop(); - } - - if (stack.length) { - const parent = stack[stack.length - 1]; - parent.position.content.end = endContent + contentOffset; - fullHeadingPath = `${parent.heading}\u001f${fullHeadingPath}`; - } - - positions[fullHeadingPath] = currentHeading; - stack.push({ heading: fullHeadingPath, position: currentHeading }); - } - }); - - return positions; -} - -function getBlockPositions( - document: string, - tokens: marked.TokensList, - contentOffset: number -): Record { - const positions: Record = {}; - - let lastBlockDetails: - | { - token: marked.Token; - start: number; - end: number; - } - | undefined = undefined; - let startContent = 0; - let endContent = 0; - let endMarker = 0; - marked.walkTokens(tokens, (token) => { - const blockReferenceRegex = /[^\S\r\n]*\^([a-zA-Z0-9_-]+)\s*$/; - const found = document.indexOf(token.raw, startContent); - if (found === -1) { - // Token's raw text is not present in the document starting from the - // current position. This happens for inner tokens of blockquotes, - // whose raw omits the leading "> " prefix, so the text never appears - // verbatim in the document. Skip the token entirely to avoid - // corrupting the shared position state with -1-based arithmetic. - return; - } - startContent = found; - const match = blockReferenceRegex.exec(token.raw); - endContent = startContent + (match ? match.index : token.raw.length); - const startMarker = match ? startContent + match.index : -1; - endMarker = startContent + token.raw.length; - // The end of a list item token sometimes doesn't include the trailing - // newline -- i'm honestly not sure why, but treating it as - // included here would simplify my implementation - if ( - document.slice(endMarker - 1, endMarker) !== "\n" && - document.slice(endMarker, endMarker + 1) === "\n" - ) { - endMarker += 1; - } else if ( - document.slice(endMarker - 2, endMarker) !== "\r\n" && - document.slice(endMarker, endMarker + 2) === "\r\n" - ) { - endMarker += 2; - } - if (CAN_INCLUDE_BLOCK_REFERENCE.includes(token.type) && match) { - const name = match[1]; - if (!name || match.index === undefined) { - return; - } - - const finalStartContent = { - start: startContent, - end: endContent, - }; - if ( - finalStartContent.start === finalStartContent.end && - lastBlockDetails - ) { - finalStartContent.start = lastBlockDetails.start; - finalStartContent.end = lastBlockDetails.end; - } - - positions[name] = { - content: { - start: finalStartContent.start + contentOffset, - end: finalStartContent.end + contentOffset, - }, - marker: { - start: startMarker + contentOffset, - end: endMarker + contentOffset, - }, - }; - } - - if (TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE.includes(token.type)) { - // Apply the same trailing-newline adjustment as endMarker: if the raw - // doesn't include the trailing newline but the document has one right - // after, treat it as included so the -1 correctly strips it. - let adjustedEndContent = endContent; - if ( - document.slice(adjustedEndContent - 1, adjustedEndContent) !== "\n" && - document.slice(adjustedEndContent, adjustedEndContent + 1) === "\n" - ) { - adjustedEndContent += 1; - } else if ( - document.slice(adjustedEndContent - 2, adjustedEndContent) !== "\r\n" && - document.slice(adjustedEndContent, adjustedEndContent + 2) === "\r\n" - ) { - adjustedEndContent += 2; - } - lastBlockDetails = { - token: token, - start: startContent, - end: adjustedEndContent - 1, - }; - } - }); - - return positions; -} - -function preProcess(document: string): PreprocessedDocument { - const frontmatterRegex = - /^---(?:\r\n|\r|\n)(?:---(?:\r\n|\r|\n|$)|([\s\S]*?)(?:\r\n|\r|\n)---(?:\r\n|\r|\n|$))/; - - let content: string; - let contentOffset = 0; - let frontmatter: Record; - - const match = frontmatterRegex.exec(document); - if (match) { - const frontmatterText = (match[1] ?? "").trim(); // Captured frontmatter content - contentOffset = match[0].length; // Length of the entire frontmatter section including delimiters - - try { - frontmatter = parseYaml(frontmatterText) ?? {}; - } catch (e) { - throw new FrontmatterParseError( - `Could not parse document frontmatter: ${(e as Error).message}` - ); - } - content = document.slice(contentOffset); - } else { - content = document; - frontmatter = {}; - } - - return { - content, - contentOffset, - frontmatter, - }; -} - -/** - * @deprecated Use {@link buildModel} with {@link projectMap} instead. This is - * the 1.x map: heading paths come back as `::`-joined strings, block ids carry - * a leading `^`, and there is no `version` token for optimistic concurrency. It - * will be removed in a future major release. - */ -export const getDocumentMap = (document: string): DocumentMap => { - const { frontmatter, contentOffset, content } = preProcess(document); - - const lexer = new marked.Lexer(); - const tokens = lexer.lex(content); - - const lineEnding = document.indexOf("\r\n") > -1 ? "\r\n" : "\n"; - - return { - heading: getHeadingPositions(content, tokens, contentOffset), - block: getBlockPositions(content, tokens, contentOffset), - frontmatter: frontmatter, - contentOffset: contentOffset, - lineEnding, - }; -}; diff --git a/src/patch.ts b/src/patch.ts deleted file mode 100644 index 86a6ea0..0000000 --- a/src/patch.ts +++ /dev/null @@ -1,777 +0,0 @@ -import { getDocumentMap } from "./map.js"; -import * as marked from "marked"; -import * as yaml from "yaml"; -import { - AppendTableRowsBlockPatchInstruction, - PrependTableRowsBlockPatchInstruction, - DocumentMap, - DocumentMapMarkerContentPair, - TextExtendingPatchInstruction, - PatchInstruction, - ReplaceTableRowsBlockPatchInstruction, - BaseHeadingPatchInstruction, - BaseBlockPatchInstruction, - AppendableFrontmatterType, - PatchTargetScope, -} from "./types.js"; -import { ContentType } from "./types.js"; -import { - isAppendableFrontmatterType, - isDictionary, - isList, - isString, - isStringArray, - isStringArrayArray, -} from "./typeGuards.js"; -import { DEFAULT_TARGET_SCOPE } from "./constants.js"; - -export enum PatchFailureReason { - InvalidTarget = "invalid-target", - ContentAlreadyPreexistsInTarget = "content-already-preexists-in-target", - TableContentIncorrectColumnCount = "table-content-incorrect-column-count", - ContentTypeInvalid = "content-type-invalid", - ContentTypeInvalidForTarget = "content-type-invalid-for-target", - ContentNotMergeable = "content-not-mergeable", -} - -const describeInstructionTarget = (instruction: PatchInstruction): string => { - if (instruction.targetType === "heading") { - const path = instruction.target; - return path === null || path.length === 0 - ? "document root" - : `heading "${path.join(" > ")}"`; - } - if (instruction.targetType === "block") { - return `block "^${instruction.target}"`; - } - return `frontmatter field "${instruction.target}"`; -}; - -const buildPatchFailedMessage = ( - reason: PatchFailureReason, - instruction: PatchInstruction -): string => { - const op = instruction.operation; - const target = describeInstructionTarget(instruction); - switch (reason) { - case PatchFailureReason.InvalidTarget: - return `Cannot ${op} ${target}: target not found in document`; - case PatchFailureReason.ContentAlreadyPreexistsInTarget: - return `Cannot ${op} ${target}: content already exists at target`; - case PatchFailureReason.TableContentIncorrectColumnCount: - return `Cannot ${op} ${target}: row column count does not match table`; - case PatchFailureReason.ContentTypeInvalid: - return `Cannot ${op} ${target}: content is not valid for this operation`; - case PatchFailureReason.ContentTypeInvalidForTarget: - return `Cannot ${op} ${target}: target block is not a table`; - case PatchFailureReason.ContentNotMergeable: - return `Cannot ${op} ${target}: content type is not compatible with merge`; - } -}; - -export class PatchFailed extends Error { - public reason: PatchFailureReason; - public instruction: PatchInstruction; - public targetMap: DocumentMapMarkerContentPair | null; - - constructor( - reason: PatchFailureReason, - instruction: PatchInstruction, - targetMap: DocumentMapMarkerContentPair | null - ) { - super(buildPatchFailedMessage(reason, instruction)); - this.reason = reason; - this.instruction = instruction; - this.targetMap = targetMap; - this.name = "PatchFailed"; - - Object.setPrototypeOf(this, new.target.prototype); - } -} - -export class PatchError extends Error {} - -export class MergeNotPossible extends Error {} - -const getEffectiveRange = ( - target: DocumentMapMarkerContentPair, - targetScope: PatchTargetScope = DEFAULT_TARGET_SCOPE -): { start: number; end: number } => { - if (targetScope === "marker") { - return { start: target.marker.start, end: target.marker.end }; - } - if (targetScope === "markerAndContent") { - return { - start: Math.min(target.marker.start, target.content.start), - end: Math.max(target.marker.end, target.content.end), - }; - } - return { start: target.content.start, end: target.content.end }; -}; - -const replaceText = ( - document: string, - instruction: PatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - const targetScope = "targetScope" in instruction ? instruction.targetScope : DEFAULT_TARGET_SCOPE; - const { start, end } = getEffectiveRange(target, targetScope); - const suffix = document.slice(end); - // Some block tokens (e.g. tables) have their raw include the blank-line - // separator, so content.end lands on that separator \n rather than just - // after the content's own trailing \n. The suffix then starts with only - // one \n instead of the two that form the blank line. Restore the missing - // newline so the blank line is preserved when the replacement has no - // trailing newline of its own. - const lineEnding = suffix.startsWith("\r\n") ? "\r\n" : "\n"; - const hasSingleLeadingNewline = - suffix.startsWith(lineEnding) && !suffix.startsWith(lineEnding + lineEnding); - const docLineEnding = document.indexOf("\r\n") > -1 ? "\r\n" : "\n"; - // For heading sections, content.end sits right at the start of the next - // heading, so the blank-line separator between sections is inside the - // content region (not in the suffix). If the original document had a - // blank line immediately before content.end, preserve it after the - // replacement. - const hadTrailingBlankLine = - suffix.length > 0 && - !suffix.startsWith(docLineEnding) && - end >= docLineEnding.length * 2 && - document.slice( - end - docLineEnding.length * 2, - end - ) === docLineEnding + docLineEnding; - - let content = instruction.content; - if (typeof content === "string") { - if (hasSingleLeadingNewline && !content.endsWith("\n") && !content.endsWith("\r\n")) { - content = content + lineEnding; - } else if (hadTrailingBlankLine && !content.endsWith(docLineEnding + docLineEnding)) { - content = content.endsWith(docLineEnding) - ? content + docLineEnding - : content + docLineEnding + docLineEnding; - } - } - return [ - document.slice(0, start), - content, - suffix, - ].join(""); -}; - -const prependText = ( - document: string, - instruction: TextExtendingPatchInstruction & PatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - const targetScope = "targetScope" in instruction ? instruction.targetScope : DEFAULT_TARGET_SCOPE; - const { start } = getEffectiveRange(target, targetScope); - return [ - document.slice(0, start), - instruction.content, - instruction.trimTargetWhitespace - ? document.slice(start).trimStart() - : document.slice(start), - ].join(""); -}; - -const appendText = ( - document: string, - instruction: TextExtendingPatchInstruction & PatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - const targetScope = "targetScope" in instruction ? instruction.targetScope : DEFAULT_TARGET_SCOPE; - const { end } = getEffectiveRange(target, targetScope); - const suffix = document.slice(end); - const lineEnding = document.indexOf("\r\n") > -1 ? "\r\n" : "\n"; - // For heading sections, content.end sits right at the start of the next - // heading, so the blank-line separator between sections is inside the - // content region and ends up *before* the appended content. Detect this - // by checking for a trailing blank line immediately before content.end and, - // unless the caller explicitly trimmed target whitespace, restore the - // separator *after* the new content instead. - const hadTrailingBlankLine = - !instruction.trimTargetWhitespace && - suffix.length > 0 && - !suffix.startsWith(lineEnding) && - end >= lineEnding.length * 2 && - document.slice( - end - lineEnding.length * 2, - end - ) === lineEnding + lineEnding; - - let content = instruction.content; - if ( - hadTrailingBlankLine && - typeof content === "string" && - !content.endsWith(lineEnding + lineEnding) - ) { - content = content.endsWith(lineEnding) - ? content + lineEnding - : content + lineEnding + lineEnding; - } - - if (instruction.trimTargetWhitespace) { - return [ - document.slice(0, end).trimEnd(), - content, - suffix, - ].join(""); - } - - // When this is the last section (suffix is empty), trailing blank lines in - // the content region are just document-level whitespace, not section - // separators. Strip them so the new content follows directly without a - // visual gap. - let insertionEnd = end; - if (suffix.length === 0) { - const doubleEnding = lineEnding + lineEnding; - while ( - insertionEnd >= lineEnding.length * 2 && - document.slice(insertionEnd - lineEnding.length * 2, insertionEnd) === - doubleEnding - ) { - insertionEnd -= lineEnding.length; - } - } - - return [document.slice(0, insertionEnd), content, suffix].join(""); -}; - -export class TablePartsNotFound extends Error {} - -const _getTableData = ( - document: string, - target: DocumentMapMarkerContentPair -): { - token: marked.Tokens.Table; - lineEnding: string; - headerParts: string; - contentParts: string; -} => { - const targetTable = document.slice(target.content.start, target.content.end); - const tableToken = marked.lexer(targetTable)[0]; - const match = /^(.*?)(?:\r?\n)(.*?)(\r?\n)/.exec(targetTable); - if (!(tableToken.type === "table") || !match) { - throw new TablePartsNotFound(); - } - - const lineEnding = match[3]; - return { - token: tableToken as marked.Tokens.Table, - lineEnding: match[3], - headerParts: match[1] + lineEnding + match[2] + lineEnding, - contentParts: targetTable.slice(match[0].length), - }; -}; - -const replaceTable = ( - document: string, - instruction: ReplaceTableRowsBlockPatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - try { - const table = _getTableData(document, target); - const tableRows: string[] = [table.headerParts]; - let content = instruction.content; - if (isStringArray(content)) { - // For when the request sends in just a single row - content = [content]; - } - if (isStringArrayArray(content)) { - // For when the incoming request is multiple rows - for (const row of content) { - if ( - row.length !== table.token.header.length || - typeof row === "string" - ) { - throw new PatchFailed( - PatchFailureReason.TableContentIncorrectColumnCount, - instruction, - target - ); - } - - tableRows.push("| " + row.join(" | ") + " |" + table.lineEnding); - } - } else { - throw new PatchFailed( - PatchFailureReason.ContentTypeInvalid, - instruction, - target - ); - } - - return [ - document.slice(0, target.content.start), - tableRows.join(""), - document.slice(target.content.end), - ].join(""); - } catch (TablePartsNotFound) { - throw new PatchFailed( - PatchFailureReason.ContentTypeInvalidForTarget, - instruction, - target - ); - } -}; - -const prependTable = ( - document: string, - instruction: PrependTableRowsBlockPatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - try { - const table = _getTableData(document, target); - const tableRows: string[] = [table.headerParts]; - let content = instruction.content; - if (isStringArray(content)) { - // For when the request sends in just a single row - content = [content]; - } - if (isStringArrayArray(content)) { - // For when the request sends in just a single row - if (instruction.rejectIfContentPreexists) { - const existingRows = table.token.rows.map((row) => - row.map((cell) => cell.text) - ); - const allPreexist = content.every((incomingRow) => - existingRows.some( - (existingRow) => - existingRow.length === incomingRow.length && - existingRow.every((cell, i) => cell === incomingRow[i]) - ) - ); - if (allPreexist) { - throw new PatchFailed( - PatchFailureReason.ContentAlreadyPreexistsInTarget, - instruction, - target - ); - } - } - for (const row of content) { - if ( - row.length !== table.token.header.length || - typeof row === "string" - ) { - throw new PatchFailed( - PatchFailureReason.TableContentIncorrectColumnCount, - instruction, - target - ); - } - - tableRows.push("| " + row.join(" | ") + " |" + table.lineEnding); - } - } else { - throw new PatchFailed( - PatchFailureReason.ContentTypeInvalid, - instruction, - target - ); - } - - tableRows.push(table.contentParts); - - return [ - document.slice(0, target.content.start), - tableRows.join(""), - document.slice(target.content.end), - ].join(""); - } catch (TablePartsNotFound) { - throw new PatchFailed( - PatchFailureReason.ContentTypeInvalidForTarget, - instruction, - target - ); - } -}; - -const appendTable = ( - document: string, - instruction: AppendTableRowsBlockPatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - try { - const table = _getTableData(document, target); - const tableRows: string[] = [table.headerParts, table.contentParts]; - let content = instruction.content; - if (isStringArray(content)) { - // For when the request sends in just a single row - content = [content]; - } - if (isStringArrayArray(content)) { - // For when the incoming request is multiple rows - if (instruction.rejectIfContentPreexists) { - const existingRows = table.token.rows.map((row) => - row.map((cell) => cell.text) - ); - const allPreexist = content.every((incomingRow) => - existingRows.some( - (existingRow) => - existingRow.length === incomingRow.length && - existingRow.every((cell, i) => cell === incomingRow[i]) - ) - ); - if (allPreexist) { - throw new PatchFailed( - PatchFailureReason.ContentAlreadyPreexistsInTarget, - instruction, - target - ); - } - } - for (const row of content) { - if ( - row.length !== table.token.header.length || - typeof row === "string" - ) { - throw new PatchFailed( - PatchFailureReason.TableContentIncorrectColumnCount, - instruction, - target - ); - } - - tableRows.push("| " + row.join(" | ") + " |" + table.lineEnding); - } - } else { - throw new PatchFailed( - PatchFailureReason.ContentTypeInvalid, - instruction, - target - ); - } - - return [ - document.slice(0, target.content.start), - tableRows.join(""), - document.slice(target.content.end), - ].join(""); - } catch (TablePartsNotFound) { - throw new PatchFailed( - PatchFailureReason.ContentTypeInvalidForTarget, - instruction, - target - ); - } -}; - -const replace = ( - document: string, - instruction: PatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - const contentType = - "contentType" in instruction && instruction.contentType - ? instruction.contentType - : ContentType.text; - - switch (contentType) { - case ContentType.text: - return replaceText(document, instruction, target); - case ContentType.json: - return replaceTable( - document, - instruction as ReplaceTableRowsBlockPatchInstruction, - target - ); - default: - throw new PatchError(`Unsupported contentType: ${contentType}`); - } -}; - -const prepend = ( - document: string, - instruction: TextExtendingPatchInstruction & PatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - const contentType = - "contentType" in instruction && instruction.contentType - ? instruction.contentType - : ContentType.text; - - switch (contentType) { - case ContentType.text: - return prependText(document, instruction, target); - case ContentType.json: - return prependTable( - document, - instruction as PrependTableRowsBlockPatchInstruction, - target - ); - default: - throw new PatchError(`Unsupported contentType: ${contentType}`); - } -}; - -const append = ( - document: string, - instruction: TextExtendingPatchInstruction & PatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - const contentType = - "contentType" in instruction && instruction.contentType - ? instruction.contentType - : ContentType.text; - - switch (contentType) { - case ContentType.text: - return appendText(document, instruction, target); - case ContentType.json: - return appendTable( - document, - instruction as AppendTableRowsBlockPatchInstruction, - target - ); - default: - throw new PatchError(`Unsupported contentType: ${contentType}`); - } -}; - -const addTargetHeading = ( - document: string, - instruction: TextExtendingPatchInstruction & - PatchInstruction & - BaseHeadingPatchInstruction, - map: DocumentMap -): string => { - const elements: string[] = []; - let bestTarget = map.heading[""]; - for (const element of instruction.target ?? []) { - const possibleMatch = map.heading[[...elements, element].join("\u001f")]; - if (possibleMatch) { - elements.push(element); - bestTarget = possibleMatch; - } else { - break; - } - } - let finalContent = ""; - let existingLevels = elements.length; - if ( - document.slice( - bestTarget.content.end - map.lineEnding.length, - bestTarget.content.end - ) !== map.lineEnding - ) { - finalContent += map.lineEnding; - } - for (const headingPart of (instruction.target ?? []).slice(existingLevels)) { - existingLevels += 1; - finalContent += `${"#".repeat(existingLevels)} ${headingPart}${map.lineEnding}`; - } - finalContent += instruction.content; - - return [ - document.slice(0, bestTarget.content.end), - finalContent, - document.slice(bestTarget.content.end), - ].join(""); -}; - -const addTargetBlock = ( - document: string, - instruction: TextExtendingPatchInstruction & - PatchInstruction & - BaseBlockPatchInstruction, - map: DocumentMap -): string => { - return ( - document + - map.lineEnding + - instruction.content + - map.lineEnding + - map.lineEnding + - "^" + - instruction.target - ); -}; - -const addTarget = ( - document: string, - instruction: TextExtendingPatchInstruction & - PatchInstruction & - (BaseBlockPatchInstruction | BaseHeadingPatchInstruction), - map: DocumentMap -): string => { - switch (instruction.targetType) { - case "heading": - return addTargetHeading(document, instruction, map); - case "block": - return addTargetBlock(document, instruction, map); - } -}; - -const getTarget = ( - map: DocumentMap, - instruction: PatchInstruction -): DocumentMapMarkerContentPair | undefined => { - switch (instruction.targetType) { - case "heading": - return map.heading[ - instruction.target ? instruction.target.join("\u001f") : "" - ]; - case "block": - return map.block[instruction.target]; - case "frontmatter": - return map.frontmatter[instruction.target]; - } -}; - -function mergeFrontmatterValue( - obj1: AppendableFrontmatterType, - obj2: AppendableFrontmatterType -): AppendableFrontmatterType { - if (isList(obj1) && isList(obj2)) { - return [...obj1, ...obj2]; - } else if (isDictionary(obj1) && isDictionary(obj2)) { - return { ...obj1, ...obj2 }; - } else if (isString(obj1) && isString(obj2)) { - return obj1 + obj2; - } - - throw new MergeNotPossible(); -} - -function regenerateDocumentWithFrontmatter( - frontmatter: Record, - document: string, - map: DocumentMap -): string { - const rawFrontmatterText = Object.values(frontmatter).some( - (value) => value !== undefined - ) - ? `---\n${yaml.stringify(frontmatter).trimEnd()}\n---\n` - : ""; - - const frontmatterText = - map.lineEnding !== "\n" - ? rawFrontmatterText.replaceAll("\n", map.lineEnding) - : rawFrontmatterText; - const finalDocument = document.slice(map.contentOffset); - - return frontmatterText + finalDocument; -} - -/** - * Applies a patch to the specified document. - * - * @deprecated Use {@link patch} instead. This is the 1.x engine, kept for - * backwards compatibility; it takes a {@link PatchInstruction} with a - * `::`-joined heading target and no scope algebra, and it will be removed in a - * future major release. See the README's migration table for the field mapping. - * - * @param document The document to apply the patch to. - * @param instruction The patch to apply. - * @returns The patched document - */ -export const applyPatch = ( - document: string, - instruction: PatchInstruction -): string => { - const map = getDocumentMap(document); - const target = getTarget(map, instruction); - - if ( - instruction.targetType === "block" || - instruction.targetType === "heading" - ) { - if (!target) { - if (instruction.createTargetIfMissing) { - return addTarget(document, instruction, map); - } else { - throw new PatchFailed( - PatchFailureReason.InvalidTarget, - instruction, - null - ); - } - } - if ( - instruction.operation !== "replace" && - "rejectIfContentPreexists" in instruction && - instruction.rejectIfContentPreexists && - typeof instruction.content === "string" && - (() => { - const { start, end } = getEffectiveRange( - target, - "targetScope" in instruction ? instruction.targetScope : DEFAULT_TARGET_SCOPE - ); - return document.slice(start, end).includes(instruction.content.trim()); - })() - ) { - throw new PatchFailed( - PatchFailureReason.ContentAlreadyPreexistsInTarget, - instruction, - target - ); - } - switch (instruction.operation) { - case "append": - return append(document, instruction, target); - case "prepend": - return prepend(document, instruction, target); - case "replace": - return replace(document, instruction, target); - default: - throw new PatchError("Invalid operation"); - } - } - const frontmatter = { ...map.frontmatter }; - - if (frontmatter[instruction.target] === undefined) { - if (instruction.createTargetIfMissing) { - if (isList(instruction.content)) { - frontmatter[instruction.target] = []; - } else if (isString(instruction.content)) { - frontmatter[instruction.target] = ""; - } else if (isDictionary(instruction.content)) { - frontmatter[instruction.target] = {}; - } - } else { - throw new PatchFailed( - PatchFailureReason.InvalidTarget, - instruction, - null - ); - } - } - - try { - switch (instruction.operation) { - case "append": - if (!isAppendableFrontmatterType(instruction.content)) { - throw new MergeNotPossible(); - } - frontmatter[instruction.target] = mergeFrontmatterValue( - frontmatter[instruction.target], - instruction.content - ); - break; - case "prepend": - if (!isAppendableFrontmatterType(instruction.content)) { - throw new MergeNotPossible(); - } - frontmatter[instruction.target] = mergeFrontmatterValue( - instruction.content, - frontmatter[instruction.target] - ); - break; - case "replace": - frontmatter[instruction.target] = instruction.content; - break; - } - - return regenerateDocumentWithFrontmatter(frontmatter, document, map); - } catch (error) { - if (error instanceof MergeNotPossible) { - throw new PatchFailed( - PatchFailureReason.ContentNotMergeable, - instruction, - null - ); - } - throw error; - } -}; diff --git a/src/tests/map.test.ts b/src/tests/map.test.ts deleted file mode 100644 index 7a8fc7b..0000000 --- a/src/tests/map.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; - -import { getDocumentMap } from "../map"; -import { DocumentMapMarkerContentPair } from "../types"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("map", () => { - const sample = fs.readFileSync(path.join(__dirname, "sample.md"), "utf-8"); - - test("heading", () => { - const actualHeadings = getDocumentMap(sample).heading; - - const expectedHeadings = { - "": { - content: { - start: 130, - end: 6988, - }, - marker: { - start: 0, - end: 0, - }, - level: 0, - }, - Overview: { - content: { - start: 142, - end: 430, - }, - marker: { - start: 131, - end: 142, - }, - level: 1, - }, - Problems: { - content: { - start: 441, - end: 1468, - }, - marker: { - start: 430, - end: 441, - }, - level: 1, - }, - Actions: { - content: { - start: 1478, - end: 3182, - }, - marker: { - start: 1468, - end: 1478, - }, - level: 1, - }, - Headers: { - content: { - start: 3192, - end: 4282, - }, - marker: { - start: 3182, - end: 3192, - }, - level: 1, - }, - "Page Targets": { - content: { - start: 4297, - end: 6988, - }, - marker: { - start: 4282, - end: 4297, - }, - level: 1, - }, - "Page Targets\u001fHeading": { - content: { - start: 4309, - end: 5251, - }, - marker: { - start: 4298, - end: 4309, - }, - level: 2, - }, - "Page Targets\u001fBlock": { - content: { - start: 5260, - end: 6122, - }, - marker: { - start: 5251, - end: 5260, - }, - level: 2, - }, - "Page Targets\u001fBlock\u001fUse Cases": { - content: { - start: 5778, - end: 6122, - }, - marker: { - start: 5764, - end: 5778, - }, - level: 3, - }, - "Page Targets\u001fFrontmatter Field": { - content: { - start: 6143, - end: 6690, - }, - marker: { - start: 6122, - end: 6143, - }, - level: 2, - }, - "Page Targets\u001fFrontmatter Field\u001fUse Cases": { - content: { - start: 6510, - end: 6690, - }, - marker: { - start: 6496, - end: 6510, - }, - level: 3, - }, - "Page Targets\u001fDocument Properties (Exploratory)": { - content: { - start: 6727, - end: 6988, - }, - marker: { - start: 6690, - end: 6727, - }, - level: 2, - }, - }; - - //console.log(JSON.stringify(actualHeadings, undefined, 4)); - - expect(actualHeadings).toEqual(expectedHeadings); - }); - - test("block", () => { - const actualBlocks = getDocumentMap(sample).block; - const expectedBlocks: Record = { - "2c67a6": { - content: { - start: 1478, - end: 3172, - }, - marker: { - start: 3173, - end: 3181, - }, - }, - "1d6271": { - content: { - start: 3192, - end: 4272, - }, - marker: { - start: 4273, - end: 4281, - }, - }, - bfec1f: { - content: { - start: 4310, - end: 4606, - }, - marker: { - start: 4607, - end: 4615, - }, - }, - "259a73": { - content: { - start: 6570, - end: 6633, - }, - marker: { - start: 6633, - end: 6642, - }, - }, - e6068e: { - content: { - start: 6642, - end: 6681, - }, - marker: { - start: 6681, - end: 6690, - }, - }, - }; - - //console.log(JSON.stringify(actualBlocks, undefined, 4)); - - expect(actualBlocks).toEqual(expectedBlocks); - }); - - describe("frontmatter", () => { - test("exists", () => { - const actualFrontmatter = getDocumentMap(sample).frontmatter; - - const expectedFrontmatter = { - aliases: ["Structured Markdown Patch"], - "project-type": "Technical", - repository: "https://github.com/coddingtonbear/markdown-patch", - }; - - expect(expectedFrontmatter).toEqual(actualFrontmatter); - }); - - - test("exists but is empty", () => { - const actual = getDocumentMap("---\n---\n# H\n"); - - expect(actual.frontmatter).toEqual({}); - expect(actual.contentOffset).toBe(8); - expect(actual.heading.H.marker.start).toBe(8); - }); - - test("exists but is empty with CRLF line endings", () => { - const actual = getDocumentMap("---\r\n---\r\n# H\r\n"); - - expect(actual.frontmatter).toEqual({}); - expect(actual.contentOffset).toBe(10); - expect(actual.heading.H.marker.start).toBe(10); - }); - - test("does not treat later thematic breaks as frontmatter", () => { - const actual = getDocumentMap("# H\n\n---\nbody\n---\n"); - - expect(actual.frontmatter).toEqual({}); - expect(actual.contentOffset).toBe(0); - expect(actual.heading.H.marker.start).toBe(0); - }); - - test("does not exist", () => { - const sample = fs.readFileSync( - path.join(__dirname, "sample.frontmatter.none.md"), - "utf-8" - ); - - const actualFrontmatter = getDocumentMap(sample).frontmatter; - const expectedFrontmatter = {}; - - expect(expectedFrontmatter).toEqual(actualFrontmatter); - }); - - test("does not exist, but starts with hr", () => { - const sample = fs.readFileSync( - path.join(__dirname, "sample.frontmatter.nonfrontmatter-hr.md"), - "utf-8" - ); - - const actualFrontmatter = getDocumentMap(sample).frontmatter; - const expectedFrontmatter = {}; - - expect(expectedFrontmatter).toEqual(actualFrontmatter); - }); - }); -}); diff --git a/src/tests/patch.test.ts b/src/tests/patch.test.ts deleted file mode 100644 index 5dd2683..0000000 --- a/src/tests/patch.test.ts +++ /dev/null @@ -1,1395 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; - -import { FrontmatterPatchInstruction, PatchInstruction } from "../types"; -import { applyPatch, PatchError, PatchFailed } from "../patch"; -import { FrontmatterParseError } from "../map"; -import { ContentType } from "../types"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("patch", () => { - const sample = fs.readFileSync(path.join(__dirname, "sample.md"), "utf-8"); - const sampleFrontmatter = fs.readFileSync( - path.join(__dirname, "sample.frontmatter.md"), - "utf-8" - ); - - const assertPatchResultsMatch = ( - inputDocumentPath: string, - outputDocumentPath: string, - instruction: PatchInstruction - ) => { - const inputDocument = fs.readFileSync( - path.join(__dirname, inputDocumentPath), - "utf-8" - ); - const outputDocument = fs.readFileSync( - path.join(__dirname, outputDocumentPath), - "utf-8" - ); - - expect(applyPatch(inputDocument, instruction)).toEqual(outputDocument); - }; - - describe("heading", () => { - test("prepend", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Overview"], - operation: "prepend", - content: "Beep Boop\n", - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.prepend.md", - instruction - ); - }); - test("append", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Overview"], - operation: "append", - content: "Beep Boop\n", - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.append.md", - instruction - ); - }); - test("replace", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Overview"], - operation: "replace", - content: "Beep Boop\n", - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.replace.md", - instruction - ); - }); - describe("document", () => { - test("prepend", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: null, - operation: "prepend", - content: "Beep Boop\n", - }; - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.document.prepend.md", - instruction - ); - }); - test("append", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: null, - operation: "append", - content: "Beep Boop\n", - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.document.append.md", - instruction - ); - }); - }); - }); - - describe("parameter", () => { - describe("trimTargetWhitespace", () => { - describe("heading", () => { - test("prepend", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Page Targets", "Document Properties (Exploratory)"], - operation: "prepend", - content: "Beep Boop", - trimTargetWhitespace: true, - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.trimTargetWhitespace.prepend.md", - instruction - ); - }); - test("append", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Problems"], - operation: "append", - content: "Beep Boop\n", - trimTargetWhitespace: true, - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.trimTargetWhitespace.append.md", - instruction - ); - }); - }); - }); - - describe("createTargetIfMissing", () => { - test("nested", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Page Targets", "Block", "Test"], - operation: "replace", - content: "Beep Boop\n", - createTargetIfMissing: true, - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.createIfMissing.nested.md", - instruction - ); - }); - - test("root", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Alpha", "Beta", "Test"], - operation: "replace", - content: "Beep Boop\n", - createTargetIfMissing: true, - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.createIfMissing.root.md", - instruction - ); - }); - }); - - describe("rejectIfContentPreexists", () => { - describe("disabled (default)", () => { - describe("heading", () => { - test("preexists at target", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Page Targets"], - operation: "append", - content: "## Frontmatter Field", - // rejectIfContentPreexists: false, # default - }; - - expect(() => { - applyPatch(sample, instruction); - }).not.toThrow(PatchFailed); - }); - test("does not preexist at target", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Headers"], - operation: "append", - content: "## Frontmatter Field", - // rejectIfContentPreexists: false, # default - }; - - expect(() => { - applyPatch(sample, instruction); - }).not.toThrow(PatchFailed); - }); - }); - describe("block (table)", () => { - const tableDoc = - "| A | B |\n| --- | --- |\n| x | y |\n| p | q |\n\n^tbl1\n"; - test("append: all rows preexist — still proceeds", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "append", - contentType: ContentType.json, - content: [["x", "y"]], - // rejectIfContentPreexists: false, # default - }; - expect(() => applyPatch(tableDoc, instruction)).not.toThrow( - PatchFailed - ); - }); - test("prepend: all rows preexist — still proceeds", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "prepend", - contentType: ContentType.json, - content: [["x", "y"]], - // rejectIfContentPreexists: false, # default - }; - expect(() => applyPatch(tableDoc, instruction)).not.toThrow( - PatchFailed - ); - }); - }); - }); - describe("enabled", () => { - describe("heading", () => { - test("preexists at target", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Page Targets"], - operation: "append", - content: "## Frontmatter Field", - rejectIfContentPreexists: true, - }; - - expect(() => { - applyPatch(sample, instruction); - }).toThrow(PatchFailed); - }); - test("does not preexist at target", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Headers"], - operation: "append", - content: "## Frontmatter Field", - rejectIfContentPreexists: true, - }; - - expect(() => { - applyPatch(sample, instruction); - }).not.toThrow(PatchFailed); - }); - }); - describe("block (table)", () => { - const tableDoc = - "| A | B |\n| --- | --- |\n| x | y |\n| p | q |\n\n^tbl1\n"; - test("append: all rows preexist — throws", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "append", - contentType: ContentType.json, - content: [["x", "y"]], - rejectIfContentPreexists: true, - }; - expect(() => applyPatch(tableDoc, instruction)).toThrow(PatchFailed); - }); - test("append: multiple rows all preexist — throws", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "append", - contentType: ContentType.json, - content: [ - ["x", "y"], - ["p", "q"], - ], - rejectIfContentPreexists: true, - }; - expect(() => applyPatch(tableDoc, instruction)).toThrow(PatchFailed); - }); - test("append: at least one new row — proceeds", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "append", - contentType: ContentType.json, - content: [ - ["x", "y"], - ["z", "w"], - ], - rejectIfContentPreexists: true, - }; - expect(() => applyPatch(tableDoc, instruction)).not.toThrow( - PatchFailed - ); - }); - test("append: no rows preexist — proceeds", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "append", - contentType: ContentType.json, - content: [["z", "w"]], - rejectIfContentPreexists: true, - }; - expect(() => applyPatch(tableDoc, instruction)).not.toThrow( - PatchFailed - ); - }); - test("prepend: all rows preexist — throws", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "prepend", - contentType: ContentType.json, - content: [["p", "q"]], - rejectIfContentPreexists: true, - }; - expect(() => applyPatch(tableDoc, instruction)).toThrow(PatchFailed); - }); - test("prepend: at least one new row — proceeds", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "prepend", - contentType: ContentType.json, - content: [ - ["p", "q"], - ["z", "w"], - ], - rejectIfContentPreexists: true, - }; - expect(() => applyPatch(tableDoc, instruction)).not.toThrow( - PatchFailed - ); - }); - }); - }); - describe("never applies to replace", () => { - test("replace always applies even when incoming content is a subset of existing", () => { - const doc = "## Section\n\nfoo bar baz\n\n## Next\n\ncontent\n"; - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Section"], - operation: "replace", - content: "bar baz", - }; - expect(() => applyPatch(doc, instruction)).not.toThrow(); - }); - }); - }); - }); - describe("block", () => { - test("prepend", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "e6068e", - operation: "prepend", - content: "- OK\n", - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.prepend.md", - instruction - ); - }); - test("append", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "e6068e", - operation: "append", - content: "\n- OK", - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.append.md", - instruction - ); - }); - test("replace", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "259a73", - operation: "replace", - content: "- OK", - }; - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.replace.md", - instruction - ); - }); - describe("regression: issue #5 - blank line between content and block ID removed (table/list blocks)", () => { - test("Bug: Replace table block - blank line should be preserved", () => { - const original = - "| Col A | Col B |\n| --- | --- |\n| 1 | 2 |\n\n^table1\n"; - const replacement = "| X | Y |\n| --- | --- |\n| 9 | 8 |"; - - const instruction: PatchInstruction = { - operation: "replace", - targetType: "block", - target: "table1", - contentType: ContentType.text, - content: replacement, - }; - - const result = applyPatch(original, instruction); - const lines = result.split("\n"); - const blockIdLine = lines.findIndex((line) => line === "^table1"); - expect(lines[blockIdLine - 1]).toEqual(""); - }); - test("Bug: Replace list block - blank line should be preserved", () => { - const original = "- Item A\n- Item B\n- Item C\n\n^list1\n"; - const replacement = "- New 1\n- New 2"; - - const instruction: PatchInstruction = { - operation: "replace", - targetType: "block", - target: "list1", - contentType: ContentType.text, - content: replacement, - }; - - const result = applyPatch(original, instruction); - expect(result).toEqual("- New 1\n- New 2\n\n^list1\n"); - }); - }); - describe("regression: issue #4 - trailing bytes from old content leak into replacement (list blocks)", () => { - test("replace (isolated block ref, shorter replacement - no trailing byte leak)", () => { - const original = "- Item 1\n- Item 2\n- Item 3\n\n^list1\n"; - const instruction: PatchInstruction = { - targetType: "block", - target: "list1", - operation: "replace", - contentType: ContentType.text, - content: "- New A\n- New B", - }; - expect(applyPatch(original, instruction)).toEqual( - "- New A\n- New B\n\n^list1\n" - ); - }); - test("replace (isolated block ref, multibyte UTF-8 - no trailing byte leak)", () => { - const original = "- 項目1\n- 項目2\n- 項目3\n\n^list1\n"; - const instruction: PatchInstruction = { - targetType: "block", - target: "list1", - operation: "replace", - contentType: ContentType.text, - content: "- 新項目A\n- 新項目B", - }; - expect(applyPatch(original, instruction)).toEqual( - "- 新項目A\n- 新項目B\n\n^list1\n" - ); - }); - }); - describe("regression: issue #6 - entire document duplicated at end of file (quote blocks)", () => { - test("Bug: Replace quote block - document should not be duplicated", () => { - const original = - "# Test\n\n" + - "Paragraph ^inline1\n\n" + - "> Quote line 1\n" + - "> Quote line 2\n\n" + - "^quote1\n\n" + - "[[some link]] paragraph ^wikilink1\n"; - - const replacement = "> New quote"; - - const instruction: PatchInstruction = { - operation: "replace", - targetType: "block", - target: "quote1", - contentType: ContentType.text, - content: replacement, - }; - - const result = applyPatch(original, instruction); - - // Check for duplication - const headingCount = (result.match(/# Test/g) || []).length; - expect(headingCount).toBe(1); - - const linkCount = (result.match(/\[\[some link\]\]/g) || []).length; - expect(linkCount).toBe(1); - }); - }); - describe("regression: issue #7 - heading append/replace consumes trailing blank line, corrupting subsequent heading targets", () => { - // Core bug cases - test("append: blank line before next section is preserved", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item", - }; - const result = applyPatch(original, instruction); - expect(result).toEqual( - "## Section A\n\nContent A.\n\n- new item\n\n## Section B\n\nContent B.\n" - ); - }); - test("append: Section B is still targetable after patching Section A", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const patched = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item", - }); - expect(() => - applyPatch(patched, { - targetType: "heading", - target: ["Section B"], - operation: "append", - content: "- another item", - }) - ).not.toThrow(); - }); - test("replace: blank line before next section is preserved", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Section A"], - operation: "replace", - content: "New content", - }; - const result = applyPatch(original, instruction); - expect(result).toEqual( - "## Section A\nNew content\n\n## Section B\n\nContent B.\n" - ); - }); - - // No double-adding when content already ends with \n\n - test("append: content already ending with \\n\\n does not get extra blank line", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item\n\n", - }); - expect(result).toEqual( - "## Section A\n\nContent A.\n\n- new item\n\n## Section B\n\nContent B.\n" - ); - }); - test("append: content ending with exactly \\n gets one more \\n (not two)", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item\n", - }); - expect(result).toEqual( - "## Section A\n\nContent A.\n\n- new item\n\n## Section B\n\nContent B.\n" - ); - }); - test("replace: content already ending with \\n\\n does not get extra blank line", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "replace", - content: "New content\n\n", - }); - expect(result).toEqual( - "## Section A\nNew content\n\n## Section B\n\nContent B.\n" - ); - }); - test("replace: content ending with \\n gets one more \\n", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "replace", - content: "New content\n", - }); - expect(result).toEqual( - "## Section A\nNew content\n\n## Section B\n\nContent B.\n" - ); - }); - - // Last section — no separator needed - test("append: no separator added when section is last in document", () => { - const original = "## Section A\n\nContent A.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item", - }); - expect(result).toEqual("## Section A\n\nContent A.\n- new item"); - }); - - // No blank line between headings — fix must not add one - test("append: no blank line added when sections are not blank-line separated", () => { - const original = "## Section A\nContent A.\n## Section B\nContent B.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item", - }); - expect(result).toEqual( - "## Section A\nContent A.\n- new item## Section B\nContent B.\n" - ); - }); - - // trimTargetWhitespace suppresses the separator restoration - test("append with trimTargetWhitespace: separator not re-added after trimmed content", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item", - trimTargetWhitespace: true, - }); - // trimTargetWhitespace strips trailing whitespace from the section; - // the caller took responsibility for whitespace, so no \n\n is restored. - expect(result).toEqual( - "## Section A\n\nContent A.- new item## Section B\n\nContent B.\n" - ); - }); - }); - describe("regression: issue #231 - append under nested heading inserts extra blank line before content", () => { - test("append to last nested section: no extra blank line when document ends with trailing blank line", () => { - // Reproduces the exact report: '# Tasks\n## Stretch\n- item1\n- item2\n\n' - // (Obsidian commonly saves files with a trailing blank line.) - // Appending '- [ ] new task' should follow immediately after the last - // list item — no double-newline gap before it. - const original = - "# Tasks\n## Stretch\n- Existing task 1\n- Existing task 2\n\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Tasks", "Stretch"], - operation: "append", - content: "- [ ] new task", - }); - expect(result).not.toContain("\n\n- [ ] new task"); - expect(result).toEqual( - "# Tasks\n## Stretch\n- Existing task 1\n- Existing task 2\n- [ ] new task" - ); - }); - test("append to flat last section: no extra blank line when document ends with trailing blank line", () => { - const original = "## Section A\n\nContent A.\n\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item", - }); - expect(result).not.toContain("\n\n- new item"); - expect(result).toEqual("## Section A\n\nContent A.\n- new item"); - }); - }); - describe("regression: issue #10 - heading boundary detection treats code-span heading inside table cell as a section boundary", () => { - const original = - "---\ntitle: fixture-table-codespan\n---\n\n" + - "## Journal\n\n" + - "| Date | Event |\n" + - "| ---------- | -------------------------------------------------------------------------------------- |\n" + - "| 2026-01-01 | Initial entry. Discusses the `## Links` section below. |\n\n" + - "## Links\n\n" + - "- Parent\n"; - - test("replace: section body is fully replaced without corrupting the table", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Journal"], - operation: "replace", - content: - "| Date | Event |\n| --- | --- |\n| 2026-01-02 | Updated entry |\n", - }; - const result = applyPatch(original, instruction); - expect(result).toEqual( - "---\ntitle: fixture-table-codespan\n---\n\n" + - "## Journal\n" + - "| Date | Event |\n| --- | --- |\n| 2026-01-02 | Updated entry |\n\n" + - "## Links\n\n" + - "- Parent\n" - ); - }); - - test("replace: Links section is still correctly targetable after patching Journal", () => { - const first = applyPatch(original, { - targetType: "heading", - target: ["Journal"], - operation: "replace", - content: - "| Date | Event |\n| --- | --- |\n| 2026-01-02 | Updated entry |\n", - }); - const second = applyPatch(first, { - targetType: "heading", - target: ["Links"], - operation: "replace", - content: "- Child\n", - }); - expect(second).toEqual( - "---\ntitle: fixture-table-codespan\n---\n\n" + - "## Journal\n" + - "| Date | Event |\n| --- | --- |\n| 2026-01-02 | Updated entry |\n\n" + - "## Links\n" + - "- Child\n" - ); - }); - }); - describe("regression: issue #10 variants - boundary false-match on heading-like text in section body", () => { - // Variant B: code-span heading ref in table cell, nested target path - test("replace nested target: code-span in table cell does not corrupt section", () => { - const original = - "## Root\n\n" + - "### Journal\n\n" + - "| Date | Event |\n" + - "| --- | --- |\n" + - "| 2026-01-01 | See the `## Sibling` section. |\n\n" + - "## Sibling\n\n" + - "- Item\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Root", "Journal"], - operation: "replace", - content: "REPLACEMENT.\n", - }); - expect(result).toEqual( - "## Root\n\n" + - "### Journal\n" + - "REPLACEMENT.\n\n" + - "## Sibling\n\n" + - "- Item\n" - ); - }); - - // Variant C: bare `## heading` substring in plain prose (no table, no code-span) - test("replace: bare heading substring in prose does not become a section boundary", () => { - const original = - "## Journal\n\n" + - "Original content. This refers to ## Heading below.\n\n" + - "## Heading\n\n" + - "- Item\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Journal"], - operation: "replace", - content: "REPLACEMENT.\n", - }); - expect(result).toEqual( - "## Journal\n" + - "REPLACEMENT.\n\n" + - "## Heading\n\n" + - "- Item\n" - ); - }); - - // Variant D: code-span heading ref in plain paragraph (no table) - test("replace: code-span heading ref in paragraph does not become a section boundary", () => { - const original = - "## Journal\n\n" + - "Original content. See the `## Heading` section below.\n\n" + - "## Heading\n\n" + - "- Item\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Journal"], - operation: "replace", - content: "REPLACEMENT.\n", - }); - expect(result).toEqual( - "## Journal\n" + - "REPLACEMENT.\n\n" + - "## Heading\n\n" + - "- Item\n" - ); - }); - }); - describe("tagetBlockTypeBehavior", () => { - describe("table (multiple)", () => { - test("prepend", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "2c67a6", - operation: "prepend", - contentType: ContentType.json, - content: [ - ["`something else`", "Some other application", "✅", "✅", "✅"], - ], - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.targetBlockTypeBehavior.table.prepend.md", - instruction - ); - }); - test("append", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "2c67a6", - operation: "append", - contentType: ContentType.json, - content: [ - ["`something else`", "Some other application", "✅", "✅", "✅"], - ], - }; - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.targetBlockTypeBehavior.table.append.md", - instruction - ); - }); - test("replace", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "2c67a6", - operation: "replace", - contentType: ContentType.json, - content: [ - ["`something else`", "Some other application", "✅", "✅", "✅"], - ], - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.targetBlockTypeBehavior.table.replace.md", - instruction - ); - }); - }); - describe("table (single)", () => { - test("prepend", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "2c67a6", - operation: "prepend", - contentType: ContentType.json, - content: [ - "`something else`", - "Some other application", - "✅", - "✅", - "✅", - ], - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.targetBlockTypeBehavior.table.prepend.md", - instruction - ); - }); - test("append", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "2c67a6", - operation: "append", - contentType: ContentType.json, - content: [ - "`something else`", - "Some other application", - "✅", - "✅", - "✅", - ], - }; - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.targetBlockTypeBehavior.table.append.md", - instruction - ); - }); - test("replace", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "2c67a6", - operation: "replace", - contentType: ContentType.json, - content: [ - "`something else`", - "Some other application", - "✅", - "✅", - "✅", - ], - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.targetBlockTypeBehavior.table.replace.md", - instruction - ); - }); - }); - }); - }); - describe("targetScope", () => { - describe("heading", () => { - test("replace: targetScope:markerAndContent replaces heading line along with content", () => { - const doc = "# Old Title\nSome content here.\n# Next\nOther.\n"; - const result = applyPatch(doc, { - targetType: "heading", - target: ["Old Title"], - operation: "replace", - targetScope: "markerAndContent", - content: "# New Title\nNew content.\n", - }); - expect(result).toEqual("# New Title\nNew content.\n# Next\nOther.\n"); - }); - - test("prepend: targetScope:markerAndContent inserts before heading line", () => { - const doc = "# Section\nContent here.\n# Next\nOther.\n"; - const result = applyPatch(doc, { - targetType: "heading", - target: ["Section"], - operation: "prepend", - targetScope: "markerAndContent", - content: "---\n", - }); - expect(result).toEqual("---\n# Section\nContent here.\n# Next\nOther.\n"); - }); - }); - - describe("block", () => { - test("replace: targetScope:markerAndContent removes block ID marker", () => { - const doc = "Some text. ^my-block\n\nNext.\n"; - const result = applyPatch(doc, { - targetType: "block", - target: "my-block", - operation: "replace", - targetScope: "markerAndContent", - content: "New text.", - }); - expect(result).toEqual("New text.\n\nNext.\n"); - }); - }); - - describe("targetScope:marker", () => { - test("heading replace: renames heading line without touching content", () => { - const doc = "# Old Title\nSome content here.\n# Next\nOther.\n"; - const result = applyPatch(doc, { - targetType: "heading", - target: ["Old Title"], - operation: "replace", - targetScope: "marker", - content: "# New Title\n", - }); - expect(result).toEqual("# New Title\nSome content here.\n# Next\nOther.\n"); - }); - - test("block replace: renames block ID without touching content", () => { - const doc = "Some text. ^old-id\n\nNext.\n"; - const result = applyPatch(doc, { - targetType: "block", - target: "old-id", - operation: "replace", - targetScope: "marker", - content: " ^new-id", - }); - expect(result).toEqual("Some text. ^new-id\n\nNext.\n"); - }); - }); - }); - - describe("regression: issue #11 - strict YAML parse failure in frontmatter blocks body-only patches", () => { - const colonInFrontmatter = fs.readFileSync( - path.join(__dirname, "sample.frontmatter.colon-in-value.md"), - "utf-8" - ); - - test("block replace throws FrontmatterParseError when frontmatter contains an unescaped colon in a scalar value", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "block-1", - operation: "replace", - content: "New content.", - }; - expect(() => applyPatch(colonInFrontmatter, instruction)).toThrow(FrontmatterParseError); - }); - - test("heading replace throws FrontmatterParseError when frontmatter contains an unescaped colon in a scalar value", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Body"], - operation: "replace", - content: "New content.\n", - }; - expect(() => applyPatch(colonInFrontmatter, instruction)).toThrow(FrontmatterParseError); - }); - }); - - describe("frontmatter", () => { - describe("append", () => { - test("mismatched types", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "append", - contentType: ContentType.json, - content: "OK", - }; - - expect(() => { - applyPatch(sample, instruction); - }).toThrow(PatchFailed); - }); - test("invalid type", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "append", - contentType: ContentType.json, - content: 10, - }; - - expect(() => { - applyPatch(sample, instruction); - }).toThrow(PatchFailed); - }); - test("type mismatch in existing field throws PatchFailed", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "append", - contentType: ContentType.json, - content: "OK", - }; - - expect(() => { - applyPatch(sampleFrontmatter, instruction); - }).toThrow(PatchFailed); - }); - test("list", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "append", - contentType: ContentType.json, - content: ["Beep"], - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.append.list.md", - instruction - ); - }); - test("dictionary", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "object-value", - operation: "append", - contentType: ContentType.json, - content: { three: "Beep" }, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.append.dictionary.md", - instruction - ); - }); - test("string", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "string-value", - operation: "append", - contentType: ContentType.json, - content: "Beep", - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.append.string.md", - instruction - ); - }); - }); - describe("prepend", () => { - test("mismatched types", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "prepend", - contentType: ContentType.json, - content: "OK", - }; - - expect(() => { - applyPatch(sample, instruction); - }).toThrow(PatchFailed); - }); - test("invalid type", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "prepend", - contentType: ContentType.json, - content: 10, - }; - - expect(() => { - applyPatch(sample, instruction); - }).toThrow(PatchFailed); - }); - test("type mismatch in existing field throws PatchFailed", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "prepend", - contentType: ContentType.json, - content: "OK", - }; - - expect(() => { - applyPatch(sampleFrontmatter, instruction); - }).toThrow(PatchFailed); - }); - test("list", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "prepend", - contentType: ContentType.json, - content: ["Beep"], - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.prepend.list.md", - instruction - ); - }); - test("dictionary", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "object-value", - operation: "prepend", - contentType: ContentType.json, - content: { three: "Beep" }, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.prepend.dictionary.md", - instruction - ); - }); - test("string", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "string-value", - operation: "prepend", - contentType: ContentType.json, - content: "Beep", - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.prepend.string.md", - instruction - ); - }); - }); - describe("replace", () => { - test("list", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "replace", - contentType: ContentType.json, - content: ["Replaced"], - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.replace.list.md", - instruction - ); - }); - test("dictionary", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "object-value", - operation: "replace", - contentType: ContentType.json, - content: { - replaced: true, - }, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.replace.dictionary.md", - instruction - ); - }); - test("string", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "string-value", - operation: "replace", - contentType: ContentType.json, - content: "Replaced", - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.replace.string.md", - instruction - ); - }); - test("number", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "number-value", - operation: "replace", - contentType: ContentType.json, - content: 10, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.replace.number.md", - instruction - ); - }); - test("boolean", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "boolean-value", - operation: "replace", - contentType: ContentType.json, - content: true, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.replace.boolean.md", - instruction - ); - }); - describe("createTargetIfMissing", () => { - - test("works when the existing frontmatter block is empty", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "title", - operation: "replace", - contentType: ContentType.json, - content: "T", - createTargetIfMissing: true, - }; - - expect(applyPatch("---\n---\n# H\nbody\n", instruction)).toEqual( - "---\ntitle: T\n---\n# H\nbody\n" - ); - }); - - test("list", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "new-field", - operation: "replace", - contentType: ContentType.json, - content: ["New Value"], - createTargetIfMissing: true, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.createTargetIfMissing.list.md", - instruction - ); - }); - test("dictionary", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "new-field", - operation: "replace", - contentType: ContentType.json, - content: { - newValue: true, - }, - createTargetIfMissing: true, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.createTargetIfMissing.dictionary.md", - instruction - ); - }); - test("string", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "new-field", - operation: "replace", - contentType: ContentType.json, - content: "New Value", - createTargetIfMissing: true, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.createTargetIfMissing.string.md", - instruction - ); - }); - test("number", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "new-field", - operation: "replace", - contentType: ContentType.json, - content: 588600, - createTargetIfMissing: true, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.createTargetIfMissing.number.md", - instruction - ); - }); - test("boolean", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "new-field", - operation: "replace", - contentType: ContentType.json, - content: true, - createTargetIfMissing: true, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.createTargetIfMissing.boolean.md", - instruction - ); - }); - }); - }); - }); - - describe("unknown contentType", () => { - const doc = "## Overview\n\nSome content.\n"; - const unknownContentType = "text/xml" as unknown as ContentType; - - test("replace throws for unrecognised contentType", () => { - const instruction = { - targetType: "heading", - target: ["Overview"], - operation: "replace", - content: "New content\n", - contentType: unknownContentType, - } as unknown as PatchInstruction; - - expect(() => applyPatch(doc, instruction)).toThrow(PatchError); - }); - - test("prepend throws for unrecognised contentType", () => { - const instruction = { - targetType: "heading", - target: ["Overview"], - operation: "prepend", - content: "New content\n", - contentType: unknownContentType, - } as unknown as PatchInstruction; - - expect(() => applyPatch(doc, instruction)).toThrow(PatchError); - }); - - test("append throws for unrecognised contentType", () => { - const instruction = { - targetType: "heading", - target: ["Overview"], - operation: "append", - content: "New content\n", - contentType: unknownContentType, - } as unknown as PatchInstruction; - - expect(() => applyPatch(doc, instruction)).toThrow(PatchError); - }); - }); -}); diff --git a/src/typeGuards.ts b/src/typeGuards.ts index b135037..92863be 100644 --- a/src/typeGuards.ts +++ b/src/typeGuards.ts @@ -1,29 +1,8 @@ -import { AppendableFrontmatterType } from "./types"; - -export function isStringArray(obj: unknown): obj is string[] { - // Check if the object is an array - if (!Array.isArray(obj)) return false; - - // Check if every element is a string - return obj.every((item) => typeof item === "string"); -} - -export function isStringArrayArray(obj: unknown): obj is string[][] { - // Check if the object is an array - if (!Array.isArray(obj)) return false; - - // Check if every element is an array of strings - return obj.every( - (item) => - Array.isArray(item) && - item.every((subItem) => typeof subItem === "string") - ); -} -export function isAppendableFrontmatterType( - obj: unknown -): obj is AppendableFrontmatterType { - return isString(obj) || isDictionary(obj) || isList(obj); -} +/** The value shapes a frontmatter `prepend`/`append` can merge into. */ +export type AppendableFrontmatterType = + | string + | Array + | Record; export function isString(obj: unknown): obj is string { return typeof obj === "string"; @@ -36,3 +15,9 @@ export function isDictionary(obj: unknown): obj is Record { export function isList(obj: unknown): obj is Array { return Array.isArray(obj); } + +export function isAppendableFrontmatterType( + obj: unknown +): obj is AppendableFrontmatterType { + return isString(obj) || isDictionary(obj) || isList(obj); +} diff --git a/src/types.ts b/src/types.ts index 829a36e..cf0fa74 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,303 +1,5 @@ +/** A half-open byte range `[start, end)` into a document string. */ export interface DocumentRange { start: number; end: number; } - -export interface DocumentMapMarkerContentPair { - marker: DocumentRange; - content: DocumentRange; -} - -export interface HeadingMarkerContentPair extends DocumentMapMarkerContentPair { - level: number; -} - -export interface DocumentMap { - heading: Record; - block: Record; - frontmatter: Record; - contentOffset: number; - lineEnding: string; -} - -export type PatchTargetType = "heading" | "block" | "frontmatter"; - -export type PatchOperation = "replace" | "prepend" | "append"; - -export interface BasePatchInstructionTarget { - targetType: PatchTargetType; - target: any; - createTargetIfMissing?: boolean; -} - -export interface BasePatchInstructionOperation { - operation: string; -} - -export type PatchTargetScope = "content" | "marker" | "markerAndContent"; - -export interface BaseMarkerContentPatchInstruction - extends BasePatchInstructionTarget { - /** Controls the range of the document the patch operates on. - * - * - `"content"` (default): patch applies only to the content region, leaving the marker unchanged. - * - `"marker"`: patch applies only to the marker (heading line or block ID), leaving the content unchanged. - * - `"markerAndContent"`: patch applies to the full range covering both the marker and content, - * allowing the heading line or block ID to be replaced or repositioned alongside the content. - */ - targetScope?: PatchTargetScope; -} - -export interface BaseHeadingPatchInstruction - extends BaseMarkerContentPatchInstruction { - targetType: "heading"; - target: string[] | null; -} - -export interface BaseFrontmatterPatchInstruction - extends BasePatchInstructionTarget { - targetType: "frontmatter"; - target: string; -} - -export interface BaseBlockPatchInstruction - extends BaseMarkerContentPatchInstruction { - targetType: "block"; - target: string; -} - -export interface NonExtendingPatchInstruction - extends BasePatchInstructionOperation {} - -export interface TextExtendingPatchInstruction - extends BasePatchInstructionOperation { - /** Trim whitepsace from target before joining with content - * - * - For `prepend`: Trims content from the beginning of - * the target content. - * - For `append`: Trims content from the end of the target - * content. Your content should probably end in a newline - * in this case, or the trailing heading will no longer - * be the start of its own line - */ - trimTargetWhitespace?: boolean; - /** Reject patch if content already exists at target - * - * By default, a patch is always applied regardless of whether the - * supplied content already appears in the target. Set - * `rejectIfContentPreexists` to `true` to instead fail with - * `ContentAlreadyPreexistsInTarget` when the content is found — - * useful as an idempotency guard so a retry does not duplicate content. - */ - rejectIfContentPreexists?: boolean; -} - -export interface StringContent { - contentType?: ContentType.text; - content: string; -} - -export interface JsonContent { - contentType: ContentType.json; - content: unknown; -} - -/** - * Prepend content to content existing under a heading - * - * @category Patch Instructions - */ -export interface PrependHeadingPatchInstruction - extends TextExtendingPatchInstruction, - BaseHeadingPatchInstruction, - StringContent { - operation: "prepend"; -} - -/** - * Append content to content existing under a heading - * - * @category Patch Instructions - */ -export interface AppendHeadingPatchInstruction - extends TextExtendingPatchInstruction, - BaseHeadingPatchInstruction, - StringContent { - operation: "append"; -} - -/** - * Replace content under a heading - * - * @category Patch Instructions - */ -export interface ReplaceHeadingPatchInstruction - extends NonExtendingPatchInstruction, - BaseHeadingPatchInstruction, - StringContent { - operation: "replace"; -} - -/** - * Prepend content to a block referenced by a block reference - * - * @category Patch Instructions - */ -export interface PrependBlockPatchInstruction - extends TextExtendingPatchInstruction, - BaseBlockPatchInstruction, - StringContent { - operation: "prepend"; -} - -/** - * Append content to a block referenced by a block reference - * - * @category Patch Instructions - */ -export interface AppendBlockPatchInstruction - extends TextExtendingPatchInstruction, - BaseBlockPatchInstruction, - StringContent { - operation: "append"; -} - -/** - * Replace content of block referenced by a block reference. - * - * @category Patch Instructions - */ -export interface ReplaceBlockPatchInstruction - extends NonExtendingPatchInstruction, - BaseBlockPatchInstruction, - StringContent { - operation: "replace"; -} - -/** - * Prepend rows to a table referenced by a block reference. - * - * @category Patch Instructions - */ -export interface PrependTableRowsBlockPatchInstruction - extends TextExtendingPatchInstruction, - BaseBlockPatchInstruction, - JsonContent { - operation: "prepend"; -} - -/** - * Patch Instruction for appending rows to a table - * referenced by a block reference. - * - * @category Patch Instructions - */ -export interface AppendTableRowsBlockPatchInstruction - extends TextExtendingPatchInstruction, - BaseBlockPatchInstruction, - JsonContent { - operation: "append"; -} - -/** - * Patch Instruction for replacing all rows of a table - * referenced by a block reference. - * - * @category Patch Instructions - */ -export interface ReplaceTableRowsBlockPatchInstruction - extends NonExtendingPatchInstruction, - BaseBlockPatchInstruction, - JsonContent { - operation: "replace"; -} - -/** - * Prepend content to a frontmatter field - * - * @category Patch Instructions - */ -export interface PrependFrontmatterPatchInstruction - extends NonExtendingPatchInstruction, - BaseFrontmatterPatchInstruction, - JsonContent { - operation: "prepend"; -} - -/** - * Append content to a frontmatter field - * - * @category Patch Instructions - */ -export interface AppendFrontmatterPatchInstruction - extends NonExtendingPatchInstruction, - BaseFrontmatterPatchInstruction, - JsonContent { - operation: "append"; -} - -/** - * Replace content of frontmatter field - * - * @category Patch Instructions - */ -export interface ReplaceFrontmatterPatchInstruction - extends NonExtendingPatchInstruction, - BaseFrontmatterPatchInstruction, - JsonContent { - operation: "replace"; -} - -/** - * Patch Instruction for Headings - */ -export type HeadingPatchInstruction = - | PrependHeadingPatchInstruction - | AppendHeadingPatchInstruction - | ReplaceHeadingPatchInstruction; - -/** - * Patch Instruction for Block References - */ -export type BlockPatchInstruction = - | PrependBlockPatchInstruction - | AppendBlockPatchInstruction - | ReplaceBlockPatchInstruction - | PrependTableRowsBlockPatchInstruction - | AppendTableRowsBlockPatchInstruction - | ReplaceTableRowsBlockPatchInstruction; - -export type FrontmatterPatchInstruction = - | PrependFrontmatterPatchInstruction - | AppendFrontmatterPatchInstruction - | ReplaceFrontmatterPatchInstruction; - -/** - * Patch Instruction - */ -export type PatchInstruction = - | HeadingPatchInstruction - | BlockPatchInstruction - | FrontmatterPatchInstruction; - -export enum ContentType { - /** - * Content is simple markdown text. - */ - text = "text/markdown", - /** - * Content is a JSON document - */ - json = "application/json", -} - -export interface PreprocessedDocument { - frontmatter: Record; - contentOffset: number; - content: string; -} - -export type AppendableFrontmatterType = - | string - | Array - | Record; From dff09d3b6470244f3ceed9737b7faad9c22b2413 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 07:16:12 -0500 Subject: [PATCH 54/73] Fill in package metadata for the 2.0 release Declare types and a single-entry exports map (deep dist/ imports are no longer public surface), require node >=20 to match marked@17, clean and rebuild dist on prepack so stale build output can never ship, and add the repository/bugs/homepage/keywords/author fields. DocumentModel and its node types are now exported so the exports-locked surface still names buildModel's and projectMap's types. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015Y4vyHDvFCMPe2VRVp8H7B --- package.json | 32 ++++++++++++++++++++++++++++++-- src/index.ts | 6 ++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 329d841..9dd17b0 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,20 @@ "name": "markdown-patch", "version": "2.0.0", "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "engines": { + "node": ">=20" + }, "scripts": { "build": "tsc", + "prepack": "rm -rf dist && npm run build", "test": "NODE_OPTIONS=--experimental-vm-modules jest", "docs": "typedoc", "docs-serve": "http-server docs" @@ -29,8 +41,24 @@ "bin": { "mdpatch": "./dist/cli.js" }, - "keywords": [], - "author": "", + "repository": { + "type": "git", + "url": "git+https://github.com/coddingtonbear/markdown-patch.git" + }, + "bugs": { + "url": "https://github.com/coddingtonbear/markdown-patch/issues" + }, + "homepage": "https://github.com/coddingtonbear/markdown-patch#readme", + "keywords": [ + "markdown", + "patch", + "frontmatter", + "heading", + "block-reference", + "obsidian", + "cli" + ], + "author": "Adam Coddington ", "license": "ISC", "description": "Change markdown documents by inserting or changing content relative to headings or other parts of a document's structure.", "files": [ diff --git a/src/index.ts b/src/index.ts index f2f8d7b..b56a4d6 100755 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,12 @@ export type { DocumentRange } from "./types.js"; export { patch } from "./engine.js"; export { buildModel } from "./model.js"; +export type { + DocumentModel, + SectionNode, + BlockNode, + FrontmatterEntry, +} from "./model.js"; export { projectMap, headingTreePaths } from "./projection.js"; export type { PublicMap, HeadingTree } from "./projection.js"; export { readTarget } from "./read.js"; From d1069cbbe1d64287430e11b32071715a8d5e1460 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 07:16:48 -0500 Subject: [PATCH 55/73] Widen zod to ^3.25.76 and add the ISC LICENSE file The exported instruction schema makes zod part of the public surface; an exact pin forces a duplicate zod copy into consumers on any other 3.x patch, which breaks instanceof checks against ZodError. A caret range within v3 keeps one shared copy. The LICENSE file makes the package.json ISC declaration real. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015Y4vyHDvFCMPe2VRVp8H7B --- LICENSE | 15 +++++++++++++++ package-lock.json | 5 ++++- package.json | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be727b8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2024-2026 Adam Coddington + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/package-lock.json b/package-lock.json index f9401dd..2c06be3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "commander": "^12.1.0", "marked": "^17.0.1", "yaml": "^2.5.1", - "zod": "3.25.76" + "zod": "^3.25.76" }, "bin": { "mdpatch": "dist/cli.js" @@ -28,6 +28,9 @@ "ts-node": "^10.9.2", "typedoc": "^0.26.6", "typescript": "^5.5.4" + }, + "engines": { + "node": ">=20" } }, "node_modules/@ampproject/remapping": { diff --git a/package.json b/package.json index 9dd17b0..769d33e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "commander": "^12.1.0", "marked": "^17.0.1", "yaml": "^2.5.1", - "zod": "3.25.76" + "zod": "^3.25.76" }, "devDependencies": { "@types/jest": "^29.5.12", From 8fcd4f439217ff08bea8293a4c5a862fc6c848cb Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 07:22:21 -0500 Subject: [PATCH 56/73] Relicense from ISC to MIT Same permissive terms; MIT is the more widely recognized name and never trips license-allowlist tooling. Changed before anything ships under the 2.0 line. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015Y4vyHDvFCMPe2VRVp8H7B --- LICENSE | 30 ++++++++++++++++++------------ package.json | 2 +- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/LICENSE b/LICENSE index be727b8..1ab65ba 100644 --- a/LICENSE +++ b/LICENSE @@ -1,15 +1,21 @@ -ISC License +MIT License Copyright (c) 2024-2026 Adam Coddington -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json index 769d33e..a6b1e61 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "cli" ], "author": "Adam Coddington ", - "license": "ISC", + "license": "MIT", "description": "Change markdown documents by inserting or changing content relative to headings or other parts of a document's structure.", "files": [ "dist/", From 20c0908cc8b6365200f462e7632f6a47f570be56 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 10:01:51 -0500 Subject: [PATCH 57/73] Make whitespace library-owned for heading writes, honoring Design Principle 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This intentionally reverses commit 4f84d89 ("Correct the documented whitespace behavior"). That commit resolved the mismatch between the documented whitespace rules and the engine by rewriting the docs to match the engine's "spliced verbatim / a leading \n buys the blank line" behavior. That was the wrong direction: it re-imposed caller- owned newline bookkeeping — the original #1 LLM caller pain 2.0 set out to eliminate — and left a naive append able to merge into the body's last paragraph, a semantic change, not a cosmetic one. The engine now implements the principle as designed: - Caller content is reduced to trimmed, canonical form: leading and trailing blank lines are stripped and a non-empty fragment ends with exactly one newline, so "X", "X\n", "\nX\n", and "X\n\n" all produce the same document. - At any joint where spliced content faces body text — append against the body's last line, prepend against the body's first — the engine supplies the blank-line separator that keeps the content its own block. Separators are only ever added, never rewritten. - Joints that are already self-delimiting get nothing: heading lines, existing blank lines, owned trailing gaps, and document edges are preserved as-is. The blank-line run between a marker and its body is treated as an owned separator (replace swaps the value beneath it, prepend inserts below it), which keeps replace-with-own-text a byte-identity in both spaced and flush document styles and preserves each document's existing formatting. - Sibling/subtree inserts pad above only when the fragment does not itself open with a heading line (a heading interrupts a paragraph; plain text does not); the joint below a subtree edit is always a marker, gap, or EOF and needs nothing. Block-target content scope is unchanged: it remains a literal splice where the caller owns the joint, per the documented contract that inline paragraph edits go through a ^id block reference. A consequence worth noting: a content-scope append/prepend now always begins a new block and can no longer continue an existing list or paragraph; the README quick start was updated accordingly, and precise intra-section placement remains future work for the positional `within` addressing already designed in the project notes. Co-Authored-By: Claude Fable 5 --- README.md | 25 ++++--- pages/overview.md | 5 +- src/engine.ts | 111 ++++++++++++++++++++++++++++-- src/schema.ts | 2 +- src/tests/docs.whitespace.test.ts | 98 +++++++++++++++++--------- src/tests/engine.test.ts | 13 ++-- src/tests/safety.test.ts | 4 +- src/text.ts | 61 +++++++++++++++- 8 files changed, 258 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 6a88b2c..8796b33 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,11 @@ const { document: patched, warnings } = patch(document, { targetType: "heading", target: ["Meeting Notes", "Action Items"], operation: "append", - content: "- Send the report\n", + content: "Decided: ship on Thursday.", }); ``` -`patch` returns `{ document, warnings }` — it does not mutate its input. +`patch` returns `{ document, warnings }` — it does not mutate its input. The appended text lands as its own paragraph, separated from the list above it by a library-supplied blank line; see [Whitespace is library-owned](#whitespace-is-library-owned). ### Relative heading levels @@ -82,9 +82,11 @@ Under `markerAndContent` (or a sibling insert) the same content lands at the tar Because the heading line is part of the `markerAndContent` span, a `replace` whose content has *no* heading removes it — the section is dissolved into a plain paragraph. Include a leading `#` (at any depth; it is rebased for you) to keep it a heading. -### Whitespace is spliced verbatim +### Whitespace is library-owned -Your `content` is inserted exactly as written at one edge of the target's span; the engine adds no whitespace of its own. For a heading, that span begins immediately *after* the heading line and ends after the last line of its subtree. So given: +Your `content` crosses the API in trimmed, canonical form: leading and trailing blank lines are stripped, and a non-empty write always ends with exactly one newline. `"X"`, `"X\n"`, `"\nX\n"`, and `"X\n\n"` all produce the same document — newlines at the edges of your content are not a channel for controlling layout, so there is nothing to get wrong. + +Blank-line separators are the engine's job. At any joint where your content faces body text, the engine supplies the blank line that keeps it a separate block. So given: ```markdown # One @@ -92,15 +94,18 @@ Your `content` is inserted exactly as written at one edge of the target's span; body of one ``` -- `prepend` lands flush against the heading line → `# One\nX\n\nbody of one\n` -- `append` lands flush against the section's last line → `# One\n\nbody of one\nX\n` -- `replace` clears the whole span, blank line included → `# One\nX\n` +- `append` becomes a new block after the body → `# One\n\nbody of one\n\nX\n` +- `prepend` becomes a new block before the body → `# One\n\nX\n\nbody of one\n` +- `replace` swaps the body → `# One\n\nX\n` + +Where no separator is owed, none is added — a heading line is self-delimiting, and existing blank lines, gaps between sections, and document edges are preserved rather than rewritten: -In all three cases **a leading `\n` in your content is what buys you a blank line before it**. Passing `"\nX\n"` instead gives `# One\n\nX\n\nbody of one\n`, `# One\n\nbody of one\n\nX\n`, and `# One\n\nX\n` respectively. +- The blank line between a heading and its body is kept in place: `replace` swaps the body beneath it and `prepend` inserts below it. A document written flush (`# One\nbody of one\n`) keeps its flush style — `replace` gives `# One\nX\n` — and replacing a body with its own text is byte-identity in either style. +- Writing into an empty section lands flush under its heading (`# E\nX\n`), with the section's existing trailing gap serving as the separator below. -Note that this is a *leading* newline even for `append`: the gap you usually want is between the existing text and yours, and that edge comes first. Trailing newlines control the gap *after* your content, and are trimmed at the very end of a document — so padding the end of an `append` at the end of a file does nothing. +One consequence worth knowing: a `content`-scope `append`/`prepend` always begins a new block — it can never continue an existing paragraph. To edit inline within a paragraph, target it via a block reference (`^id`), where content is spliced literally and you own the joint. -The case that most often surprises: prepending into a section whose heading is already followed by a blank line still yields `# One\nX`, with no gap. That blank line is part of the body, not of the boundary, so it is pushed below your text rather than kept above it. +> This contract intentionally reverses commit `4f84d89`, which documented the earlier "spliced verbatim / a leading `\n` buys the blank line" engine behavior rather than fixing it. That behavior contradicted the 2.0 design principle that the library owns whitespace, and preserved (in mutated form) the 1.x failure mode where a caller forgetting newline bookkeeping merges paragraphs. ### Frontmatter diff --git a/pages/overview.md b/pages/overview.md index 40274e3..99d707d 100644 --- a/pages/overview.md +++ b/pages/overview.md @@ -45,7 +45,7 @@ const { document: patched } = patch(document, { targetType: "heading", target: ["Discoveries"], operation: "append", - content: "\n# My discovery\n\nI discovered a thing\n", + content: "# My discovery\n\nI discovered a thing", }); ``` @@ -57,7 +57,6 @@ Note that the content says `#`, not `##`. Heading levels inside a `content` stri - Some content # Discoveries - ## My discovery I discovered a thing @@ -68,7 +67,7 @@ I discovered a thing - Caught the flight home ``` -The leading `\n` in the content above is deliberate. Content is spliced in exactly as written at the edge of the target's span, and the engine adds no whitespace of its own — without that newline, `## My discovery` would sit flush against the line above it. See the README for the full whitespace rules. +Whitespace is the library's job, not yours: content is trimmed to canonical form and the engine supplies the blank-line separators that keep your content its own block, so leading or trailing newlines in `content` change nothing. See the README for the full whitespace rules. See {@link Reference.patch} for the full instruction shape, {@link Reference.readTarget} for the read-side mirror of the same addressing, and {@link Reference.projectMap} for discovering what a document has to target. diff --git a/src/engine.ts b/src/engine.ts index b739fb4..d014fb0 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -22,7 +22,7 @@ import { subtreeEnd, blockFullRange, } from "./ranges.js"; -import { toLineEnding, sectionFragment, splice } from "./text.js"; +import { toLineEnding, sectionFragment, ownedGaps, splice } from "./text.js"; import { rebaseHeadings } from "./levels.js"; import { structuralHeading, deleteBlock } from "./engine/structural.js"; import { patchFrontmatter } from "./engine/frontmatter.js"; @@ -134,20 +134,71 @@ const patchHeading = ( if (scope === "content") { const fragment = sectionFragment(value, section.heading?.level ?? 0, model.lineEnding); - const edit = contentEdit(headingContentRange(section), operation, fragment.text); - return splice(document, [edit], fragment.warnings); + const content = headingContentRange(section); + const empty = content.start === content.end; + // The blank-line run between the marker and the first body text is a + // library-owned separator: replace swaps the value and leaves it standing, + // prepend inserts below it. (An empty replacement is a body delete and + // clears the full span, separator included.) + const bodyStart = fragment.text + ? bodyStartPast(document, content) + : content.start; + switch (operation) { + case "replace": + return splice( + document, + [{ range: { start: bodyStart, end: content.end }, text: fragment.text }], + fragment.warnings + ); + case "prepend": + // The inserted block faces body text below: the library owes the + // blank line that keeps it a separate block. + return splice( + document, + [ + blockEdit(document, model, { start: bodyStart, end: bodyStart }, fragment.text, { + padBefore: false, + padAfter: !empty, + }), + ], + fragment.warnings + ); + case "append": + // Mirror image: the joint above faces the body's last line. Below is + // the owned trailing gap (or the next marker/EOF), never body text. + return splice( + document, + [ + blockEdit(document, model, { start: content.end, end: content.end }, fragment.text, { + padBefore: !empty, + padAfter: false, + }), + ], + fragment.warnings + ); + } } if (scope === "marker") { return splice(document, [markerRenameEdit(document, model, section, operation, value)], []); } - // markerAndContent: the whole subtree, rebased to the parent's level. + // markerAndContent: the whole subtree, rebased to the parent's level. The + // joint below a subtree edit is always a heading marker, a gap, or EOF — + // self-delimiting, so no separator is owed there. Above, a separator is + // owed only when the fragment itself does not open with a heading line + // (a heading interrupts a paragraph; plain text does not). const fragment = sectionFragment(value, parentLevel(section), model.lineEnding); + const padBefore = !/^#{1,6} /.test(fragment.text); if (operation === "replace") { return splice( document, - [{ range: subtreeContentRange(section), text: fragment.text }], + [ + blockEdit(document, model, subtreeContentRange(section), fragment.text, { + padBefore, + padAfter: false, + }), + ], fragment.warnings ); } @@ -164,12 +215,32 @@ const patchHeading = ( : subtreeEnd(section); return splice( document, - [{ range: { start: at, end: at }, text: fragment.text }], + [ + blockEdit(document, model, { start: at, end: at }, fragment.text, { + padBefore, + padAfter: false, + }), + ], fragment.warnings ); }; -/** Build the edit for a `content`-scope write on a body range. */ +/** The offset of the first body text in `content`, past any leading blank run. */ +const bodyStartPast = ( + document: string, + content: { start: number; end: number } +): number => { + const leading = /^(?:[^\S\r\n]*(?:\r\n|\r|\n))+/.exec( + document.slice(content.start, content.end) + ); + return content.start + (leading ? leading[0].length : 0); +}; + +/** + * Build the edit for a `content`-scope write on a block's literal text span. + * Block content is spliced exactly as given — the caller owns the joint — since + * this is the documented contract for inline edits within a `^id` block. + */ const contentEdit = ( content: { start: number; end: number }, operation: "replace" | "prepend" | "append", @@ -185,6 +256,32 @@ const contentEdit = ( } }; +/** + * Build the edit splicing a canonical fragment over `range`, adding the + * blank-line separators the library owes on the sides where the caller says a + * separator is due (`padBefore`/`padAfter`). On a due side, `ownedGaps` + * contributes only what is missing: an existing blank line or a document edge + * needs nothing. An empty fragment clears the range without separators + * (empty replacement is deletion; an empty insert is a no-op). + */ +const blockEdit = ( + document: string, + model: DocumentModel, + range: { start: number; end: number }, + text: string, + pads: { padBefore: boolean; padAfter: boolean } +): Edit => { + if (!text) { + return { range, text }; + } + const gaps = ownedGaps(document, range, model.lineEnding); + return { + range, + text: + (pads.padBefore ? gaps.before : "") + text + (pads.padAfter ? gaps.after : ""), + }; +}; + /** Rebuild a heading line with renamed/prefixed/suffixed label text. */ const markerRenameEdit = ( document: string, diff --git a/src/schema.ts b/src/schema.ts index 06bbae1..a0d9447 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -118,7 +118,7 @@ export const InstructionInputObjectSchema = z content: z .string() .describe( - "String payload: a heading/block body or label, a new block id for a block `marker` rename (letters, numbers, hyphens, and underscores only), or a new frontmatter key name for a frontmatter `marker` rename. Heading levels are relative to the edited span (a leading `#` becomes a direct child). A heading `marker` rename may not contain a line break. Provide exactly one of `content`, `value`, or `destination`." + "String payload: a heading/block body or label, a new block id for a block `marker` rename (letters, numbers, hyphens, and underscores only), or a new frontmatter key name for a frontmatter `marker` rename. Heading levels are relative to the edited span (a leading `#` becomes a direct child). For heading writes, whitespace is library-owned: leading/trailing blank lines here are ignored, and the engine supplies the blank line that keeps inserted content a separate block — never add newlines to control spacing. (Block-target `content` is the exception: it is spliced literally for inline edits.) A heading `marker` rename may not contain a line break. Provide exactly one of `content`, `value`, or `destination`." ) .optional(), value: z diff --git a/src/tests/docs.whitespace.test.ts b/src/tests/docs.whitespace.test.ts index 8bf64b4..24d51f8 100644 --- a/src/tests/docs.whitespace.test.ts +++ b/src/tests/docs.whitespace.test.ts @@ -1,9 +1,16 @@ /** * Pins the whitespace contract the README documents under "Whitespace is - * spliced verbatim". Content is spliced in exactly as written at one edge of - * the target's span and the engine contributes no whitespace of its own, so a - * leading `\n` is what produces a blank line before the inserted text — for - * `append` as much as for `prepend`. + * library-owned". Caller content is reduced to trimmed, canonical form — + * leading and trailing blank lines are meaningless — and the engine + * contributes the blank-line separator at any joint where the spliced block + * faces body text, so a naive prepend/append can never merge into an existing + * paragraph. Joints against a heading line, an existing blank line, or a + * document edge get nothing: headings are self-delimiting, and existing + * separators and style are preserved rather than rewritten. + * + * This deliberately reverses commit 4f84d89, which documented the previous + * "spliced verbatim / a leading \n buys the blank line" behavior instead of + * fixing it to match Design Principle 1 ("the library owns whitespace"). * * These expectations are the literal strings quoted in the docs. If one of * them changes, the documentation is wrong and must change with it. @@ -11,50 +18,68 @@ import { patch } from "../engine.js"; -const doc = `# One +const spaced = `# One body of one `; -const run = (operation: "append" | "prepend" | "replace", content: string) => - patch(doc, { targetType: "heading", target: ["One"], operation, content }).document; +const flush = `# One +body of one +`; + +const run = ( + doc: string, + operation: "append" | "prepend" | "replace", + content: string +) => patch(doc, { targetType: "heading", target: ["One"], operation, content }).document; describe("documented whitespace behavior", () => { - describe("content with no leading newline lands flush against its neighbor", () => { - it("prepend butts against the heading line", () => { - expect(run("prepend", "X\n")).toBe("# One\nX\n\nbody of one\n"); + describe("caller newlines are meaningless: every edge variant produces the same document", () => { + const variants = ["X", "X\n", "X\n\n", "\nX\n", "\n\nX\n\n"]; + + test.each(variants)("append %j", (content) => { + expect(run(spaced, "append", content)).toBe("# One\n\nbody of one\n\nX\n"); }); - it("append butts against the section's last line", () => { - expect(run("append", "X\n")).toBe("# One\n\nbody of one\nX\n"); + test.each(variants)("prepend %j", (content) => { + expect(run(spaced, "prepend", content)).toBe("# One\n\nX\n\nbody of one\n"); }); - it("replace clears the span, blank line included", () => { - expect(run("replace", "X\n")).toBe("# One\nX\n"); + test.each(variants)("replace %j", (content) => { + expect(run(spaced, "replace", content)).toBe("# One\n\nX\n"); }); }); - describe("a leading newline buys a blank line before the content", () => { - it("prepend", () => { - expect(run("prepend", "\nX\n")).toBe("# One\n\nX\n\nbody of one\n"); + describe("the engine owns the separator at any joint facing body text", () => { + it("append gets a blank line between the body's last line and the new block", () => { + expect(run(spaced, "append", "X")).toBe("# One\n\nbody of one\n\nX\n"); }); - it("append", () => { - expect(run("append", "\nX\n")).toBe("# One\n\nbody of one\n\nX\n"); + it("prepend gets a blank line between the new block and the body below it", () => { + expect(run(flush, "prepend", "X")).toBe("# One\nX\n\nbody of one\n"); }); + }); - it("replace", () => { - expect(run("replace", "\nX\n")).toBe("# One\n\nX\n"); + describe("existing separators and document style are preserved, not rewritten", () => { + it("replace keeps the blank line a spaced document has between marker and body", () => { + expect(run(spaced, "replace", "X")).toBe("# One\n\nX\n"); + }); + + it("replace does not impose a blank line on a flush document", () => { + expect(run(flush, "replace", "X")).toBe("# One\nX\n"); }); - }); - it("a blank line already following a heading belongs to the body, not the boundary", () => { - // The document is well-spaced, but prepending still lands flush against the - // heading: the existing blank line is pushed below the inserted text. - expect(run("prepend", "X\n")).toBe("# One\nX\n\nbody of one\n"); + it("prepend inserts below a spaced document's marker separator", () => { + expect(run(spaced, "prepend", "X")).toBe("# One\n\nX\n\nbody of one\n"); + }); + + it("replacing a body with its own text is byte-identity in either style", () => { + expect(run(spaced, "replace", "body of one")).toBe(spaced); + expect(run(flush, "replace", "body of one")).toBe(flush); + }); }); - it("trailing padding survives mid-document but is trimmed at end of document", () => { + it("an append mid-document leaves the owned trailing gap in place", () => { const midDoc = `# One body of one @@ -68,11 +93,22 @@ body of two targetType: "heading", target: ["One"], operation: "append", - content: "X\n\n", + content: "X", }).document - ).toBe("# One\n\nbody of one\nX\n\n# Two\n\nbody of two\n"); + ).toBe("# One\n\nbody of one\n\nX\n\n# Two\n\nbody of two\n"); + }); - // At the end of the document the same trailing blank line is normalized away. - expect(run("append", "X\n\n")).toBe("# One\n\nbody of one\nX\n"); + it("writing into an empty section lands flush under its heading", () => { + // A heading line is self-delimiting, so no separator is owed above; the + // section's existing gap becomes the separator below. + const emptySection = "# E\n\n# F\nf-body\n"; + expect( + patch(emptySection, { + targetType: "heading", + target: ["E"], + operation: "append", + content: "X", + }).document + ).toBe("# E\nX\n\n# F\nf-body\n"); }); }); diff --git a/src/tests/engine.test.ts b/src/tests/engine.test.ts index 7caf2c9..702447e 100644 --- a/src/tests/engine.test.ts +++ b/src/tests/engine.test.ts @@ -47,7 +47,9 @@ describe("patch — heading content cells", () => { expect(result.warnings).toEqual([]); }); - test("prepend @ content inserts at the top of the body", () => { + test("prepend @ content inserts at the top of the body as its own block", () => { + // The library owes the blank line between the inserted block and the body + // text below it; the joint against the heading line needs none. const result = patch(DOC, { targetType: "heading", target: ["A"], @@ -56,13 +58,14 @@ describe("patch — heading content cells", () => { content: "top", }); expect(result.document).toBe( - "# A\ntop\na-body\n\n## B\nb-body\n\n# C\nc-body\n" + "# A\ntop\n\na-body\n\n## B\nb-body\n\n# C\nc-body\n" ); }); test("append @ content inserts at the bottom of the subtree body, before the gap", () => { // A's content spans through ## B, so an append lands after B's body, not - // between a-body and the subsection. + // between a-body and the subsection. The blank line separating the new + // block from the body text above it is the library's, not the caller's. const result = patch(DOC, { targetType: "heading", target: ["A"], @@ -71,7 +74,7 @@ describe("patch — heading content cells", () => { content: "bot", }); expect(result.document).toBe( - "# A\na-body\n\n## B\nb-body\nbot\n\n# C\nc-body\n" + "# A\na-body\n\n## B\nb-body\n\nbot\n\n# C\nc-body\n" ); }); @@ -425,7 +428,7 @@ describe("patch — preconditions and resolution", () => { scope: "content", content: "x", }); - expect(first.document).toContain("b-body\nx\n"); + expect(first.document).toContain("b-body\n\nx\n"); }); test("ifMatch not matching the current version fails without modifying the document", () => { diff --git a/src/tests/safety.test.ts b/src/tests/safety.test.ts index aa3eb58..5418a15 100644 --- a/src/tests/safety.test.ts +++ b/src/tests/safety.test.ts @@ -25,7 +25,7 @@ describe("rejectIfContentPreexists", () => { content: "new line", rejectIfContentPreexists: true, }); - expect(result.document).toBe("# A\nalready here\nnew line\n"); + expect(result.document).toBe("# A\nalready here\n\nnew line\n"); }); test("replace is never blocked by the guard (it overwrites)", () => { @@ -48,7 +48,7 @@ describe("rejectIfContentPreexists", () => { scope: "content", content: "already here", }); - expect(result.document).toBe("# A\nalready here\nalready here\n"); + expect(result.document).toBe("# A\nalready here\n\nalready here\n"); }); }); diff --git a/src/text.ts b/src/text.ts index ae587e4..9a908f4 100644 --- a/src/text.ts +++ b/src/text.ts @@ -38,10 +38,67 @@ export const endWithSingleEol = (text: string, ending: LineEnding): string => { return stripped.length === 0 ? "" : stripped + ending; }; +/** + * Reduce a caller-supplied fragment to trimmed, canonical form: leading blank + * lines and all trailing whitespace are dropped, and a non-empty fragment ends + * with exactly one line ending. Interior lines — including first-line + * indentation — are untouched. This is what makes leading/trailing newlines in + * caller content meaningless: separators are the library's job, not a channel + * for the caller to steer. + */ +export const canonicalFragment = (text: string, ending: LineEnding): string => { + const stripped = text + .replace(/^(?:[^\S\r\n]*(?:\r\n|\r|\n))+/, "") + .replace(/\s+$/, ""); + return stripped.length === 0 ? "" : stripped + ending; +}; + +/** Line endings (a `\r\n` pair counts as one) immediately before `at`, up to 2. */ +const eolsBefore = (document: string, at: number): number => { + let count = 0; + let i = at; + while (count < 2 && i > 0) { + if (document[i - 1] === "\n") { + i -= document[i - 2] === "\r" ? 2 : 1; + } else if (document[i - 1] === "\r") { + i -= 1; + } else { + break; + } + count += 1; + } + return count; +}; + +/** + * The blank-line separators the library owes around a block-level fragment + * spliced over `range` (a collapsed range for a pure insertion). A non-empty + * fragment always ends in a single line ending, so on each side the fragment + * must be separated from whatever survives there by a blank line: a document + * edge needs nothing, an existing blank line suffices, and flush text gets + * exactly the endings required. Separators are only ever added — existing + * gap bytes are never rewritten. + */ +export const ownedGaps = ( + document: string, + range: { start: number; end: number }, + ending: LineEnding +): { before: string; after: string } => { + const before = + range.start === 0 ? "" : ending.repeat(Math.max(0, 2 - eolsBefore(document, range.start))); + const after = + range.end === document.length || + document[range.end] === "\n" || + document[range.end] === "\r" + ? "" + : ending; + return { before, after }; +}; + /** * Turn a relative heading-bearing fragment into the exact bytes to splice in: * rebase its `#`-levels by `baseline`, re-apply the document's line ending, and - * terminate it with a single ending. + * reduce it to canonical form (blank edges trimmed, single terminator). */ export const sectionFragment = ( value: string, @@ -50,7 +107,7 @@ export const sectionFragment = ( ): { text: string; warnings: Warning[] } => { const rebased = rebaseHeadings(value, baseline); return { - text: endWithSingleEol(toLineEnding(rebased.text, ending), ending), + text: canonicalFragment(toLineEnding(rebased.text, ending), ending), warnings: rebased.warnings, }; }; From cdd0b72d8c3e1ade2fceb6313d8c450f71af7913 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 12:47:00 -0500 Subject: [PATCH 58/73] Surface each section's ordered body blocks as bodyChildren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model previously decomposed a section's direct body only into ^id-bearing blocks; every other paragraph, list, table or fence was invisible inside the opaque body range. This adds a BodyChild overlay per section: the ordered top-level blocks of the direct body, located with the same running-raw-offset technique findHeadings uses (top-level tokens tile the content region, so no anchoring is needed). Isolated ^id marker lines are not counted, matching Obsidian's section cache, which omits them — so a positional index over bodyChildren agrees with the rendered block count a reader sees. Spans strip trailing line endings, the same convention as block content spans. This is groundwork for a `within` instruction field addressing a section's Nth body block without requiring a block reference. The conformance suite now also asserts bodyChildren spans against the previously uncaptured `sections` arrays in the Obsidian goldens; a new body-children fixture awaits golden capture from live Obsidian. Co-Authored-By: Claude Fable 5 --- src/model.ts | 58 +++++++++++ src/tests/bodyChildren.test.ts | 128 +++++++++++++++++++++++++ src/tests/conformance.test.ts | 18 ++++ src/tests/conformance/body-children.md | 24 +++++ src/tests/model.property.test.ts | 12 +++ 5 files changed, 240 insertions(+) create mode 100644 src/tests/bodyChildren.test.ts create mode 100644 src/tests/conformance/body-children.md diff --git a/src/model.ts b/src/model.ts index bc52283..52ec124 100644 --- a/src/model.ts +++ b/src/model.ts @@ -32,9 +32,29 @@ export interface SectionNode { children: SectionNode[]; /** `^id`-bearing blocks that live directly in this section's body. */ blocks: BlockNode[]; + /** + * The ordered top-level blocks of this section's *direct* body (paragraphs, + * lists, tables, code fences, …), the sequence a positional `within` index + * addresses. Like {@link blocks}, an overlay rather than a partition: + * blank-line gaps and isolated `^id` marker lines belong to no child, so + * the rendered block count here matches Obsidian's section cache. + */ + bodyChildren: BodyChild[]; parent: SectionNode | null; } +/** One top-level block of a section's direct body. */ +export interface BodyChild { + /** marked token type: `paragraph`, `list`, `table`, `code`, `blockquote`, `hr`, … */ + kind: string; + /** + * The block's visible span, trailing line endings excluded (the same + * convention as {@link BlockNode.content} and Obsidian's block spans). An + * inline `^id` sits inside its block's token, so the span includes it. + */ + range: DocumentRange; +} + /** * A `^id`-bearing block. Blocks are an *overlay* onto the section tree: their * ranges fall within their containing section's {@link SectionNode.body}, @@ -230,6 +250,7 @@ const buildSectionTree = ( trailingGap: { start: 0, end: 0 }, children: [], blocks: [], + bodyChildren: [], parent: null, }; @@ -261,6 +282,7 @@ const buildSectionTree = ( trailingGap: { start: abs(split.contentEnd), end: abs(bodyEnd) }, children: [], blocks: [], + bodyChildren: [], parent: null, }; @@ -430,6 +452,41 @@ const findBlocks = ( return blocks; }; +/** + * Populate each section's {@link SectionNode.bodyChildren} from the top-level + * token stream. Top-level tokens tile the content region exactly, so a + * running raw-length offset gives exact spans (the `findHeadings` technique) + * with none of `findBlocks`' anchoring machinery, which exists only for + * descendants. `heading` and `space` tokens are structure, not body blocks; + * a paragraph that is nothing but an isolated `^id` marker line annotates the + * block above it and is likewise not counted — matching Obsidian's section + * cache, which omits such lines. + */ +const findBodyChildren = ( + content: string, + abs: Abs, + tokens: marked.TokensList, + root: SectionNode +): void => { + let offset = 0; + for (const token of tokens) { + const rawEnd = offset + token.raw.length; + if (token.type !== "heading" && token.type !== "space") { + const match = BLOCK_REFERENCE_REGEX.exec(token.raw); + const isIsolatedMarkerLine = match !== null && match.index === 0; + const end = stripTrailingEol(content, rawEnd, offset); + if (!isIsolatedMarkerLine && end > offset) { + const section = sectionContaining(root, abs(offset)); + section.bodyChildren.push({ + kind: token.type, + range: { start: abs(offset), end: abs(end) }, + }); + } + } + offset = rawEnd; + } +}; + /** * A raw heading in the source document that already ends with the exact * reserved sequence used to disambiguate a duplicate sibling heading's @@ -528,6 +585,7 @@ export const buildModel = (document: string): DocumentModel => { const headings = findHeadings(normalized, tokens); const root = buildSectionTree(normalized, abs, headings); findBlocks(normalized, abs, tokens, root); + findBodyChildren(normalized, abs, tokens, root); assertNoReservedMarkerCollisions(headings); return { diff --git a/src/tests/bodyChildren.test.ts b/src/tests/bodyChildren.test.ts new file mode 100644 index 0000000..41ba239 --- /dev/null +++ b/src/tests/bodyChildren.test.ts @@ -0,0 +1,128 @@ +import { buildModel, SectionNode } from "../model"; + +const sectionNamed = ( + model: ReturnType, + text: string +): SectionNode => { + const found = model.root.children.find((c) => c.heading?.text === text); + if (!found) throw new Error(`no section named ${text}`); + return found; +}; + +const childTexts = (doc: string, node: SectionNode): string[] => + node.bodyChildren.map((c) => doc.slice(c.range.start, c.range.end)); + +describe("findBodyChildren", () => { + test("a mixed body yields one child per top-level block, in order", () => { + const doc = [ + "# Mixed", + "", + "First paragraph.", + "", + "- one", + "- two", + "", + "```", + "fenced", + "```", + "", + "| a |", + "| - |", + "| 1 |", + "", + "> quoted", + "", + "---", + "", + "tail paragraph", + "", + ].join("\n"); + const model = buildModel(doc); + const section = sectionNamed(model, "Mixed"); + expect(section.bodyChildren.map((c) => c.kind)).toEqual([ + "paragraph", + "list", + "code", + "table", + "blockquote", + "hr", + "paragraph", + ]); + expect(childTexts(doc, section)).toEqual([ + "First paragraph.", + "- one\n- two", + "```\nfenced\n```", + "| a |\n| - |\n| 1 |", + "> quoted", + "---", + "tail paragraph", + ]); + }); + + test("an empty section body has no children", () => { + const model = buildModel("# Empty\n\n# Next\n\nbody\n"); + expect(sectionNamed(model, "Empty").bodyChildren).toEqual([]); + expect(sectionNamed(model, "Next").bodyChildren).toHaveLength(1); + }); + + test("preamble blocks before the first heading belong to the root", () => { + const doc = "Preamble one.\n\nPreamble two.\n\n# First\n\nbody\n"; + const model = buildModel(doc); + expect(childTexts(doc, model.root)).toEqual([ + "Preamble one.", + "Preamble two.", + ]); + }); + + test("children split between a section's direct body and its subsection", () => { + const doc = "# A\n\npara a\n\n## B\n\nsub para\n"; + const model = buildModel(doc); + const a = sectionNamed(model, "A"); + expect(childTexts(doc, a)).toEqual(["para a"]); + expect(childTexts(doc, a.children[0])).toEqual(["sub para"]); + }); + + test("an isolated ^id marker line is not a child; an inline ^id stays in its block's span", () => { + const doc = "# H\n\nfirst para ^inline\n\n- item\n\n^listref\n\nlast\n"; + const model = buildModel(doc); + const section = sectionNamed(model, "H"); + // The isolated `^listref` line annotates the list; it is not counted, so + // indices here match the rendered blocks a reader sees. + expect(childTexts(doc, section)).toEqual([ + "first para ^inline", + "- item", + "last", + ]); + }); + + test("a setext heading is structure, not a body child", () => { + const doc = "Title\n=====\n\nbody para\n"; + const model = buildModel(doc); + expect(model.root.bodyChildren).toEqual([]); + const title = model.root.children[0]; + expect(title.heading?.text).toEqual("Title"); + expect(childTexts(doc, title)).toEqual(["body para"]); + }); + + test("CRLF documents index children at original byte offsets", () => { + const doc = "# H\r\n\r\nfirst\r\n\r\n- a\r\n- b\r\n"; + const model = buildModel(doc); + const section = sectionNamed(model, "H"); + expect(childTexts(doc, section)).toEqual(["first", "- a\r\n- b"]); + }); + + test("a final block with no trailing newline is still a child", () => { + const doc = "# H\n\nfirst\n\nlast without newline"; + const model = buildModel(doc); + expect(childTexts(doc, sectionNamed(model, "H"))).toEqual([ + "first", + "last without newline", + ]); + }); + + test("frontmatter is not a body child", () => { + const doc = "---\ntitle: t\n---\n\nonly para\n"; + const model = buildModel(doc); + expect(childTexts(doc, model.root)).toEqual(["only para"]); + }); +}); diff --git a/src/tests/conformance.test.ts b/src/tests/conformance.test.ts index 6966baa..b4393aa 100644 --- a/src/tests/conformance.test.ts +++ b/src/tests/conformance.test.ts @@ -92,6 +92,24 @@ describe("Obsidian conformance", () => { expect([...blockIds(text)].sort()).toEqual(Object.keys(g.blocks).sort()); }); + test("body children match Obsidian's section spans", () => { + // Obsidian's section cache lists every rendered top-level block in + // document order, omitting isolated `^id` marker lines; filtering out + // heading and yaml entries leaves exactly the spans `bodyChildren` + // should produce (pre-order section traversal = document order). + const model = buildModel(text); + const modelSpans: Array<{ start: number; end: number }> = []; + eachSection(model.root, (node) => { + for (const child of node.bodyChildren) { + modelSpans.push({ start: child.range.start, end: child.range.end }); + } + }); + const obsidianSpans = g.sections + .filter((s) => s.type !== "heading" && s.type !== "yaml") + .map((s) => ({ start: s.start, end: s.end })); + expect(modelSpans).toEqual(obsidianSpans); + }); + test("each model block span equals Obsidian's block span", () => { const model = buildModel(text); eachSection(model.root, (node) => { diff --git a/src/tests/conformance/body-children.md b/src/tests/conformance/body-children.md new file mode 100644 index 0000000..d0afe53 --- /dev/null +++ b/src/tests/conformance/body-children.md @@ -0,0 +1,24 @@ +# Kinds + +First paragraph. + +Second paragraph with id. ^inline1 + +- one +- two + +^listref + +--- + +```text +fenced +``` + +> quoted line + +# Empty + +# After + +last paragraph, no trailing newline diff --git a/src/tests/model.property.test.ts b/src/tests/model.property.test.ts index a72b7f9..cd6233a 100644 --- a/src/tests/model.property.test.ts +++ b/src/tests/model.property.test.ts @@ -99,6 +99,18 @@ describe("model partition invariants", () => { } } }); + + test("body children fall inside their section body, ordered and disjoint", () => { + for (const node of collectSections(model)) { + let previousEnd = node.body.start; + for (const child of node.bodyChildren) { + expect(child.range.start).toBeGreaterThanOrEqual(previousEnd); + expect(child.range.start).toBeLessThan(child.range.end); + expect(child.range.end).toBeLessThanOrEqual(node.body.end); + previousEnd = child.range.end; + } + } + }); }); }); From 0cde65a44edbd84130a0e9a3685b7331d0be94d1 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 12:48:42 -0500 Subject: [PATCH 59/73] Resolve a within index to a section's Nth body block The resolver's Addressed heading variant gains an optional scalar `within`: after the heading resolves, resolveWithin refines it to the Nth entry of the section's bodyChildren (negative counting from the end), returning a new headingChild ResolvedTarget variant. An out-of-range index throws TargetNotFoundError naming the section and its block count, while a missing heading still returns null so createTargetIfMissing semantics for the heading itself are unaffected. readTarget shares the resolver, so within reads come along for free: a headingChild read returns the block's literal slice with no releveling, which is safe because headings are structure, never body children. Co-Authored-By: Claude Fable 5 --- src/read.ts | 6 +++ src/resolve.ts | 51 ++++++++++++++++++++-- src/tests/read.test.ts | 22 ++++++++++ src/tests/resolve.test.ts | 92 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) diff --git a/src/read.ts b/src/read.ts index ef3af68..e329199 100644 --- a/src/read.ts +++ b/src/read.ts @@ -52,6 +52,12 @@ export const readTarget = (document: string, target: ReadTarget): ReadResult => baseline === 0 ? raw : relevelText(raw, -baseline, model.lineEnding).text; return { kind: "heading", content }; } + case "headingChild": { + // A body child can contain no heading (headings are structure, not + // children), so the slice is returned literally — no releveling. + const { start, end } = resolved.child.range; + return { kind: "heading", content: document.slice(start, end) }; + } case "block": { const range = blockContentRange(resolved.block); return { kind: "block", content: document.slice(range.start, range.end) }; diff --git a/src/resolve.ts b/src/resolve.ts index 29d18f8..63509a6 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -11,16 +11,23 @@ import { BlockNode, + BodyChild, DocumentModel, FrontmatterEntry, SectionNode, eachSection, } from "./model.js"; import { allBlocksInOrder, disambiguatedBlockId, headingPath } from "./projection.js"; -import { HeadingAddress, TargetType } from "./instructions.js"; +import { + HeadingAddress, + InvalidInstructionError, + TargetNotFoundError, + TargetType, +} from "./instructions.js"; export type ResolvedTarget = | { kind: "heading"; section: SectionNode } + | { kind: "headingChild"; section: SectionNode; child: BodyChild; index: number } | { kind: "block"; block: BlockNode } | { kind: "frontmatter"; entry: FrontmatterEntry }; @@ -82,9 +89,40 @@ export const resolveFrontmatter = ( return entry ? { kind: "frontmatter", entry } : null; }; +/** + * Refine a resolved section to one of its direct body's top-level blocks by + * position (negative counts from the end). Out-of-range throws + * {@link TargetNotFoundError} — the address names a block that does not exist, + * distinct from the `null` a missing *heading* returns so that + * `createTargetIfMissing` heading semantics stay untouched. + */ +export const resolveWithin = ( + section: SectionNode, + within: number +): Extract => { + if (!Number.isInteger(within)) { + // The schema rejects this before patch() ever resolves; guard direct + // resolver/readTarget callers too. + throw new InvalidInstructionError( + `\`within\` must be an integer; got ${JSON.stringify(within)}` + ); + } + const children = section.bodyChildren; + const index = within < 0 ? children.length + within : within; + if (index < 0 || index >= children.length) { + const sectionName = section.heading + ? JSON.stringify(headingPath(section)) + : "the document root"; + throw new TargetNotFoundError( + `\`within\` index ${within} is out of range: ${sectionName} has ${children.length} top-level block${children.length === 1 ? "" : "s"}` + ); + } + return { kind: "headingChild", section, child: children[index], index }; +}; + /** The addressing subset of an instruction the resolver needs. */ export type Addressed = - | { targetType: "heading"; target: HeadingAddress } + | { targetType: "heading"; target: HeadingAddress; within?: number } | { targetType: "block"; target: string } | { targetType: "frontmatter"; target: string }; @@ -94,8 +132,13 @@ export const resolveTarget = ( instruction: Addressed ): ResolvedTarget | null => { switch (instruction.targetType) { - case "heading": - return resolveHeading(model, instruction.target); + case "heading": { + const resolved = resolveHeading(model, instruction.target); + if (resolved && instruction.within !== undefined) { + return resolveWithin(resolved.section, instruction.within); + } + return resolved; + } case "block": return resolveBlock(model, instruction.target); case "frontmatter": diff --git a/src/tests/read.test.ts b/src/tests/read.test.ts index eff5a35..2fe6624 100644 --- a/src/tests/read.test.ts +++ b/src/tests/read.test.ts @@ -110,6 +110,28 @@ describe("readTarget", () => { expect(written.document).toBe(doc); }); + test("within reads one body block of a section, literally", () => { + const doc = "# H\n\nfirst\n\n- one\n- two\n\nlast\n"; + const first = readTarget(doc, { + targetType: "heading", + target: ["H"], + within: 0, + }); + expect(first).toEqual({ kind: "heading", content: "first" }); + const list = readTarget(doc, { + targetType: "heading", + target: ["H"], + within: -2, + }); + expect(list).toEqual({ kind: "heading", content: "- one\n- two" }); + }); + + test("an out-of-range within read throws TargetNotFoundError", () => { + expect(() => + readTarget(DOC, { targetType: "heading", target: ["Other"], within: 5 }) + ).toThrow(TargetNotFoundError); + }); + test("an unresolvable target throws TargetNotFoundError", () => { expect(() => readTarget(DOC, { targetType: "heading", target: ["Nope"] }) diff --git a/src/tests/resolve.test.ts b/src/tests/resolve.test.ts index 9dc28ee..de194cd 100644 --- a/src/tests/resolve.test.ts +++ b/src/tests/resolve.test.ts @@ -1,6 +1,7 @@ import { buildModel } from "../model"; import { resolveTarget, resolveHeading, resolveBlock, ResolvedTarget } from "../resolve"; import { headingPath } from "../projection"; +import { InvalidInstructionError, TargetNotFoundError } from "../instructions"; const headingLevel = (r: ResolvedTarget | null): number | null => r && r.kind === "heading" && r.section.heading ? r.section.heading.level : null; @@ -149,3 +150,94 @@ describe("resolveTarget dispatch", () => { ).toBeNull(); }); }); + +describe("resolveWithin", () => { + const doc = [ + "# H", + "", + "first para", + "", + "- one", + "- two", + "", + "^listref", + "", + "last para", + "", + "## Sub", + "", + "sub para", + "", + ].join("\n"); + + const childText = (r: ResolvedTarget | null): string => { + if (!r || r.kind !== "headingChild") throw new Error("expected headingChild"); + return doc.slice(r.child.range.start, r.child.range.end); + }; + + test("a positive index selects the Nth body block of the section", () => { + const model = buildModel(doc); + const r = resolveTarget(model, { + targetType: "heading", + target: ["H"], + within: 1, + }); + expect(r?.kind).toBe("headingChild"); + expect(childText(r)).toBe("- one\n- two"); + }); + + test("a negative index counts from the end of the direct body", () => { + const model = buildModel(doc); + // -1 is "last para": the subsection's blocks are not part of H's direct + // body, and the isolated ^listref line is not counted. + const r = resolveTarget(model, { + targetType: "heading", + target: ["H"], + within: -1, + }); + expect(childText(r)).toBe("last para"); + expect((r as { index: number }).index).toBe(2); + }); + + test("within over the document root addresses the preamble", () => { + const preambleDoc = "Preamble.\n\n# First\n\nbody\n"; + const model = buildModel(preambleDoc); + const r = resolveTarget(model, { + targetType: "heading", + target: null, + within: 0, + }); + expect(r?.kind).toBe("headingChild"); + if (r?.kind === "headingChild") { + expect(preambleDoc.slice(r.child.range.start, r.child.range.end)).toBe( + "Preamble." + ); + } + }); + + test("an out-of-range index throws TargetNotFoundError naming the count", () => { + const model = buildModel(doc); + for (const within of [3, -4]) { + expect(() => + resolveTarget(model, { targetType: "heading", target: ["H"], within }) + ).toThrow(TargetNotFoundError); + expect(() => + resolveTarget(model, { targetType: "heading", target: ["H"], within }) + ).toThrow(/has 3 top-level blocks/); + } + }); + + test("a non-integer index throws InvalidInstructionError", () => { + const model = buildModel(doc); + expect(() => + resolveTarget(model, { targetType: "heading", target: ["H"], within: 0.5 }) + ).toThrow(InvalidInstructionError); + }); + + test("a missing heading still returns null, within or not", () => { + const model = buildModel(doc); + expect( + resolveTarget(model, { targetType: "heading", target: ["Nope"], within: 0 }) + ).toBeNull(); + }); +}); From 1bbf58941b6a8babf569826dd2ba1e0191093f5c Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 12:59:35 -0500 Subject: [PATCH 60/73] Add the within field: edit a section's Nth body block without a ^id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library-owned whitespace made heading content-scope append/prepend always begin a new block, which removed the ability to continue an existing paragraph or list unless it carried a ^id block reference. `within` restores that: a scalar index (negative counting from the end) refines a heading target to one top-level block of its direct body, and content-scope replace/prepend/append/delete on it use the same literal-splice, caller-owns-the-joint contract as ^id block content edits — so append with "\n- item" extends a list in place. Additionally, prepend/append @ markerAndContent insert a new sibling block beside the addressed one with library-owned separators, anchored past any isolated ^id marker line so markers stay bound to their blocks. Schema, TS union, and engine land together deliberately: accepting the field before the engine dispatched on it would let a within instruction validate and then silently edit the whole section. Rejected combos (non-heading targets, marker/parent scope, replace/delete @ markerAndContent, createTargetIfMissing) are enforced in the schema's cross-field refinement. rejectIfContentPreexists scans only the addressed block for content-scope writes, and the whole section body for sibling inserts, keeping retries idempotent. The field is a scalar, deliberately narrower than the number[] sketched in earlier design notes: nested [table, row] addressing is speculative and partially superseded by the value table-row carrier, and a scalar can widen to number | number[] later without breaking anyone. Co-Authored-By: Claude Fable 5 --- src/engine.ts | 118 +++++++++++++++- src/engine/structural.ts | 5 +- src/index.ts | 6 + src/instructions.ts | 45 +++++- src/schema.ts | 41 ++++++ src/tests/engine.test.ts | 296 +++++++++++++++++++++++++++++++++++++++ src/tests/read.test.ts | 18 +++ src/tests/schema.test.ts | 78 +++++++++++ 8 files changed, 599 insertions(+), 8 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index d014fb0..4a6aa39 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -12,7 +12,7 @@ * modules and later increments; until then those cells raise a clear error. */ -import { buildModel, DocumentModel, SectionNode, BlockNode } from "./model.js"; +import { buildModel, DocumentModel, SectionNode, BlockNode, BodyChild } from "./model.js"; import { resolveTarget } from "./resolve.js"; import { Edit } from "./splice.js"; import { @@ -24,7 +24,7 @@ import { } from "./ranges.js"; import { toLineEnding, sectionFragment, ownedGaps, splice } from "./text.js"; import { rebaseHeadings } from "./levels.js"; -import { structuralHeading, deleteBlock } from "./engine/structural.js"; +import { structuralHeading, deleteBlock, consumeTrailingBlank } from "./engine/structural.js"; import { patchFrontmatter } from "./engine/frontmatter.js"; import { createHeading, createBlock } from "./engine/create.js"; import { patchTableRows } from "./engine/table.js"; @@ -32,6 +32,7 @@ import { Instruction, InstructionInput, HeadingInstruction, + HeadingWithinInstruction, BlockInstruction, PatchResult, EngineError, @@ -42,6 +43,7 @@ import { assertValidCell, withDefaultScope, isBlockTableRowInstruction, + isWithinInstruction, } from "./instructions.js"; import { InstructionInputSchema } from "./schema.js"; import { ResolvedTarget } from "./resolve.js"; @@ -87,6 +89,16 @@ const scopeSpanText = ( : blockFullRange(block); return document.slice(range.start, range.end); } + if (resolved.kind === "headingChild") { + // `content`: the addressed block itself. `markerAndContent` (sibling + // insert): the whole section body, so a retried insert is still refused + // even though the new block lands outside the addressed child. + const range = + scope === "content" + ? resolved.child.range + : headingContentRange(resolved.section); + return document.slice(range.start, range.end); + } return null; }; @@ -106,6 +118,14 @@ const preexistsProbe = ( resolved: ResolvedTarget, scope: string ): string => { + if (resolved.kind === "headingChild") { + // A content-scope within write is a literal splice (never rebased); a + // markerAndContent sibling insert splices a fragment rebased to the + // section's level, so compare what the write path would produce. + return scope === "markerAndContent" + ? rebaseHeadings(content, resolved.section.heading?.level ?? 0).text + : content; + } if (resolved.kind !== "heading") { return content; } @@ -123,7 +143,10 @@ const preexistsProbe = ( const patchHeading = ( document: string, model: DocumentModel, - instruction: HeadingInstruction, + // `within` instructions resolve to a headingChild and dispatch to + // patchHeadingChild instead; excluding them here keeps the write-narrowing + // below (to HeadingWriteInstruction) sound. + instruction: Exclude, section: SectionNode ): PatchResult => { if (instruction.operation === "delete" || instruction.scope === "parent") { @@ -305,6 +328,70 @@ const markerRenameEdit = ( return { range, text: "#".repeat(level) + " " + newText + eol }; }; +// --- Heading body-block (within) handlers -------------------------------- + +/** + * Apply a `within`-refined heading instruction to one top-level block of the + * section's direct body. `content`-scope writes are literal splices — the + * caller owns the joint, exactly the `^id` block contract, which is what lets + * an `append` *continue* an existing paragraph or list — and `content`-scope + * delete removes the block plus its separator. `markerAndContent` + * `prepend`/`append` insert a *new* block beside the addressed one, with the + * library-owned separators every new-block insertion gets. + */ +const patchHeadingChild = ( + document: string, + model: DocumentModel, + instruction: HeadingWithinInstruction, + section: SectionNode, + child: BodyChild, + index: number +): PatchResult => { + if (instruction.scope === "markerAndContent") { + // Sibling insert. Levels in the fragment are relative to the section, + // the same baseline as a content-scope write into the same body. + const fragment = sectionFragment( + instruction.content, + section.heading?.level ?? 0, + model.lineEnding + ); + // "append after child k" ≡ "insert before child k+1" (or at the body end + // for the last child): anchoring on the next sibling keeps the insert + // *past* any isolated `^id` marker line annotating the addressed child, + // so the marker stays bound to its block. + const siblings = section.bodyChildren; + const at = + instruction.operation === "prepend" + ? child.range.start + : index + 1 < siblings.length + ? siblings[index + 1].range.start + : section.body.end; + return splice( + document, + [ + blockEdit(document, model, { start: at, end: at }, fragment.text, { + // The heading line above the body start, and the owned trailing + // gap / next marker / EOF below the body end, are self-delimiting. + padBefore: at !== section.body.start, + padAfter: at !== section.body.end, + }), + ], + fragment.warnings + ); + } + if (instruction.operation === "delete") { + const end = consumeTrailingBlank(document, child.range.end); + return splice( + document, + [{ range: { start: child.range.start, end }, text: "" }], + [] + ); + } + // Literal splice on the block's own span; only line endings are normalized. + const value = toLineEnding(instruction.content, model.lineEnding); + return splice(document, [contentEdit(child.range, instruction.operation, value)], []); +}; + // --- Block handlers ------------------------------------------------------ const patchBlock = ( @@ -437,8 +524,29 @@ export const patch = ( } // `resolveTarget` dispatches on `targetType`, so the resolved kind always - // matches the instruction; narrow explicitly for the type system. - if (instruction.targetType === "heading" && resolved.kind === "heading") { + // matches the instruction; narrow explicitly for the type system. A + // `within` instruction resolves to a headingChild and only ever reaches + // patchHeadingChild — patchHeading never sees one. + if (instruction.targetType === "heading" && resolved.kind === "headingChild") { + if (!isWithinInstruction(instruction)) { + throw new EngineError( + "resolved a headingChild for an instruction without `within`" + ); + } + return patchHeadingChild( + document, + model, + instruction, + resolved.section, + resolved.child, + resolved.index + ); + } + if ( + instruction.targetType === "heading" && + resolved.kind === "heading" && + !isWithinInstruction(instruction) + ) { return patchHeading(document, model, instruction, resolved.section); } if (instruction.targetType === "block" && resolved.kind === "block") { diff --git a/src/engine/structural.ts b/src/engine/structural.ts index 3eb6b75..ec5644b 100644 --- a/src/engine/structural.ts +++ b/src/engine/structural.ts @@ -192,9 +192,10 @@ export const structuralHeading = ( /** * Consume the block's line terminator and, if one follows, a single blank-line - * separator, so removing a block does not leave a dangling gap. + * separator, so removing a block does not leave a dangling gap. Shared with + * the `within` body-block delete in engine.ts, which follows the same contract. */ -const consumeTrailingBlank = (document: string, from: number): number => { +export const consumeTrailingBlank = (document: string, from: number): number => { let i = from; const eatEol = (): boolean => { if (document[i] === "\r" && document[i + 1] === "\n") { diff --git a/src/index.ts b/src/index.ts index b56a4d6..8bee9ac 100755 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export type { DocumentModel, SectionNode, BlockNode, + BodyChild, FrontmatterEntry, } from "./model.js"; export { projectMap, headingTreePaths } from "./projection.js"; @@ -30,6 +31,7 @@ export { isValidCell, assertValidCell, isBlockTableRowInstruction, + isWithinInstruction, } from "./instructions.js"; export { InstructionInputSchema, @@ -47,6 +49,10 @@ export type { HeadingWriteInstruction, HeadingMoveInstruction, HeadingDeleteInstruction, + HeadingWithinInstruction, + HeadingWithinWriteInstruction, + HeadingWithinDeleteInstruction, + HeadingWithinSiblingInsertInstruction, BlockInstruction, BlockWriteInstruction, BlockMarkerReplaceInstruction, diff --git a/src/instructions.ts b/src/instructions.ts index 66b1af7..a48cf08 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -106,10 +106,53 @@ export interface HeadingDeleteInstruction extends HeadingTargeted { operation: "delete"; scope: "content" | "marker" | "markerAndContent"; } +/** + * `replace`/`prepend`/`append` on one positionally-addressed top-level block of + * a heading's direct body (`within`: 0-based document order, negative counting + * from the end). Unlike a plain heading content write, the text is spliced + * *literally* — the caller owns the joint, exactly as with a `^id` block + * content edit — so `append` can continue an existing paragraph or list. + */ +export interface HeadingWithinWriteInstruction extends HeadingTargeted { + operation: "replace" | "prepend" | "append"; + scope: "content"; + /** Index into the section's direct-body top-level blocks; negative from the end. */ + within: number; + content: string; +} +/** `delete` one positionally-addressed body block (and its separator). */ +export interface HeadingWithinDeleteInstruction extends HeadingTargeted { + operation: "delete"; + scope: "content"; + within: number; +} +/** + * `prepend`/`append @ markerAndContent` beside a positionally-addressed body + * block: insert `content` as a *new sibling block* immediately before/after it, + * with library-owned blank-line separators (the whitespace contract for new + * blocks — only the `content`-scope cells above splice literally). + */ +export interface HeadingWithinSiblingInsertInstruction extends HeadingTargeted { + operation: "prepend" | "append"; + scope: "markerAndContent"; + within: number; + content: string; +} +export type HeadingWithinInstruction = + | HeadingWithinWriteInstruction + | HeadingWithinDeleteInstruction + | HeadingWithinSiblingInsertInstruction; export type HeadingInstruction = | HeadingWriteInstruction | HeadingMoveInstruction - | HeadingDeleteInstruction; + | HeadingDeleteInstruction + | HeadingWithinInstruction; + +/** True when a heading instruction addresses a positional body block. */ +export const isWithinInstruction = ( + instruction: HeadingInstruction +): instruction is HeadingWithinInstruction => + "within" in instruction && instruction.within !== undefined; // --- Block instructions -------------------------------------------------- diff --git a/src/schema.ts b/src/schema.ts index a0d9447..1515f44 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -104,6 +104,13 @@ export const InstructionInputObjectSchema = z .describe( "The node to edit. For a heading: an array of heading texts from the top level down to the target (e.g. [\"Overview\",\"Details\"]), or null/[] for the document root. If a heading is a duplicate of an earlier sibling under the same parent, its address carries an extra non-printable marker suffix appended by the server — copy that address verbatim from wherever the document's heading structure was discovered, never retype or reconstruct it. For a block: the bare block id, without the leading `^` (letters, numbers, hyphens, and underscores only) — a duplicate block id's later occurrence carries the same kind of marker suffix, copied verbatim the same way. For a frontmatter field: the key." ), + within: z + .number() + .int() + .optional() + .describe( + "Refines a heading target to one of the section's direct-body top-level blocks (a paragraph, list, table, code fence, blockquote, …): 0 is the first block in document order, and a negative index counts from the end (-1 = last). Isolated `^id` lines are not counted, so indices match the rendered blocks. With scope `content`, the edit is a literal splice into that block — you own the joint, so `append` *continues* the block (e.g. content `\\n- item` extends a list) — and `delete` removes the block. With scope `markerAndContent`, `prepend`/`append` insert a new block immediately before/after it, with library-owned blank-line separators. Heading targets only; cannot be combined with `createTargetIfMissing`." + ), operation: z .enum(operationValues) .describe( @@ -250,6 +257,40 @@ const instructionAlgebra = ( }); } + // `within` sub-matrix: a positional body-block refinement narrows the + // heading cells to the ones meaningful for a single block — every + // `content`-scope operation (literal splice / delete of the block itself) + // plus `prepend`/`append @ markerAndContent` (sibling block insert). + if (input.within !== undefined) { + if (targetType !== "heading") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["within"], + message: "`within` applies only to a heading target", + }); + } else { + const withinValid = + scope === "content" || + (scope === "markerAndContent" && + (operation === "prepend" || operation === "append")); + if (!withinValid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["operation"], + message: `${operation} @ ${scope} is not a valid operation for a within-refined heading target`, + }); + } + } + if (input.createTargetIfMissing) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["createTargetIfMissing"], + message: + "`createTargetIfMissing` cannot be combined with `within`: a positional block cannot be created by its index", + }); + } + } + // Cell validity. if (!isValidCell(targetType, operation, scope)) { ctx.addIssue({ diff --git a/src/tests/engine.test.ts b/src/tests/engine.test.ts index 702447e..b18a634 100644 --- a/src/tests/engine.test.ts +++ b/src/tests/engine.test.ts @@ -587,3 +587,299 @@ describe("patch — no-op identity", () => { expect(instruction.operation).toBe("replace"); }); }); + +describe("patch — within (positional body-block) cells", () => { + // Section A has three body blocks: a paragraph, a list, and a table; the + // subsection and B's body must never be touched by a within edit on A. + const WDOC = [ + "# A", + "", + "intro para", + "", + "- one", + "- two", + "", + "| a |", + "| - |", + "| 1 |", + "", + "## Sub", + "", + "sub body", + "", + "# B", + "", + "b body", + "", + ].join("\n"); + + describe("content scope: literal splice, the caller owns the joint", () => { + test("append with a leading newline extends a list in place", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 1, + operation: "append", + content: "\n- three", + }); + expect(result.document).toContain("- one\n- two\n- three\n\n| a |"); + }); + + test("append without a newline continues the block's last line", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "append", + content: " (continued)", + }); + expect(result.document).toContain("intro para (continued)\n\n- one"); + }); + + test("prepend splices at the block's first byte", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "prepend", + content: "OK: ", + }); + expect(result.document).toContain("# A\n\nOK: intro para\n"); + }); + + test("replace swaps exactly the block's own text", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: -1, + operation: "replace", + content: "plain text instead of a table", + }); + expect(result.document).toContain( + "- two\n\nplain text instead of a table\n\n## Sub" + ); + expect(result.document).not.toContain("| a |"); + }); + + test("replace with the block's own text is a byte identity", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 1, + operation: "replace", + content: "- one\n- two", + }); + expect(result.document).toBe(WDOC); + }); + + test("a negative index counts from the end of the direct body", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: -3, + operation: "prepend", + content: "X", + }); + expect(result.document).toContain("Xintro para"); + }); + + test("delete removes the block and its separator", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "delete", + }); + expect(result.document).toContain("# A\n\n- one\n- two\n\n| a |"); + expect(result.document).not.toContain("intro para"); + }); + + test("delete of the last block leaves the next heading separated", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: -1, + operation: "delete", + }); + expect(result.document).toContain("- one\n- two\n\n## Sub"); + expect(result.document).not.toContain("| a |"); + }); + + test("delete of a final block at EOF with no trailing newline", () => { + const doc = "# H\n\nfirst\n\nlast without newline"; + const result = patch(doc, { + targetType: "heading", + target: ["H"], + within: -1, + operation: "delete", + }); + expect(result.document).toBe("# H\n\nfirst\n\n"); + }); + + test("a CRLF document keeps its line endings through a within append", () => { + const doc = "# H\r\n\r\n- a\r\n- b\r\n"; + const result = patch(doc, { + targetType: "heading", + target: ["H"], + within: 0, + operation: "append", + content: "\n- c", + }); + expect(result.document).toBe("# H\r\n\r\n- a\r\n- b\r\n- c\r\n"); + }); + }); + + describe("markerAndContent scope: sibling block insert, library-owned separators", () => { + test("prepend before the first block in a spaced document", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "prepend", + scope: "markerAndContent", + content: "new first block", + }); + expect(result.document).toContain( + "# A\n\nnew first block\n\nintro para\n" + ); + }); + + test("prepend before the first block in a flush document stays flush at the heading", () => { + const doc = "# H\nbody line\n"; + const result = patch(doc, { + targetType: "heading", + target: ["H"], + within: 0, + operation: "prepend", + scope: "markerAndContent", + content: "new block", + }); + expect(result.document).toBe("# H\nnew block\n\nbody line\n"); + }); + + test("append between two blocks pads both joints", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "append", + scope: "markerAndContent", + content: "between", + }); + expect(result.document).toContain( + "intro para\n\nbetween\n\n- one\n- two" + ); + }); + + test("append after the last block lands before the subsection", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: -1, + operation: "append", + scope: "markerAndContent", + content: "after the table", + }); + expect(result.document).toContain( + "| 1 |\n\nafter the table\n\n## Sub" + ); + }); + + test("append after a ^id-annotated block falls past its marker line", () => { + const doc = "# H\n\n- item\n\n^ref\n\nlast\n"; + const result = patch(doc, { + targetType: "heading", + target: ["H"], + within: 0, + operation: "append", + scope: "markerAndContent", + content: "inserted", + }); + // The isolated marker stays bound to the list it annotates. + expect(result.document).toBe( + "# H\n\n- item\n\n^ref\n\ninserted\n\nlast\n" + ); + }); + + test("a fragment opening with # becomes a child heading of the section", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "append", + scope: "markerAndContent", + content: "# Inserted\nwith body", + }); + // Relative levels: a leading `#` is one level below A (an h1), so h2. + expect(result.document).toContain( + "intro para\n\n## Inserted\nwith body\n\n- one" + ); + }); + }); + + describe("guards", () => { + test("an out-of-range index throws TargetNotFoundError", () => { + expect(() => + patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 3, + operation: "append", + content: "x", + }) + ).toThrow(TargetNotFoundError); + }); + + test("a stale ifMatch fails before the index is even resolved", () => { + expect(() => + patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 99, + operation: "append", + content: "x", + ifMatch: "not-the-version", + }) + ).toThrow(PreconditionFailedError); + }); + + test("rejectIfContentPreexists refuses content already in the addressed block", () => { + expect(() => + patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 1, + operation: "append", + content: "\n- one", + rejectIfContentPreexists: true, + }) + ).toThrow(ContentPreexistsError); + }); + + test("rejectIfContentPreexists ignores matches outside the addressed block", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 1, + operation: "append", + content: "\n- intro para", + rejectIfContentPreexists: true, + }); + expect(result.document).toContain("- two\n- intro para\n"); + }); + + test("rejectIfContentPreexists on a sibling insert scans the whole section body", () => { + expect(() => + patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "append", + scope: "markerAndContent", + content: "- one\n- two", + rejectIfContentPreexists: true, + }) + ).toThrow(ContentPreexistsError); + }); + }); +}); diff --git a/src/tests/read.test.ts b/src/tests/read.test.ts index 2fe6624..6f17b98 100644 --- a/src/tests/read.test.ts +++ b/src/tests/read.test.ts @@ -126,6 +126,24 @@ describe("readTarget", () => { expect(list).toEqual({ kind: "heading", content: "- one\n- two" }); }); + test("a within read round-trips through a within replace as byte identity", () => { + const doc = "# H\n\nfirst\n\n- one\n- two\n\nlast\n"; + const result = readTarget(doc, { + targetType: "heading", + target: ["H"], + within: 1, + }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "heading", + target: ["H"], + within: 1, + operation: "replace", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + test("an out-of-range within read throws TargetNotFoundError", () => { expect(() => readTarget(DOC, { targetType: "heading", target: ["Other"], within: 5 }) diff --git a/src/tests/schema.test.ts b/src/tests/schema.test.ts index 036ed0d..222cd41 100644 --- a/src/tests/schema.test.ts +++ b/src/tests/schema.test.ts @@ -90,6 +90,25 @@ const valid: { name: string; instruction: InstructionInput }[] = [ name: "frontmatter delete", instruction: { targetType: "frontmatter", target: "a", operation: "delete" }, }, + { + name: "heading within write (literal splice into a body block)", + instruction: { targetType: "heading", target: ["A"], within: -1, operation: "append", content: "\n- x" }, + }, + { + name: "heading within delete", + instruction: { targetType: "heading", target: ["A"], within: 0, operation: "delete" }, + }, + { + name: "heading within sibling insert", + instruction: { + targetType: "heading", + target: ["A"], + within: 1, + operation: "prepend", + scope: "markerAndContent", + content: "new block", + }, + }, ]; describe("InstructionInputSchema", () => { @@ -201,6 +220,65 @@ describe("InstructionInputSchema", () => { name: "a heading marker rename containing an embedded newline", instruction: { targetType: "heading", target: ["A"], operation: "replace", scope: "marker", content: "New\nline" }, }, + { + name: "within on a block target", + instruction: { targetType: "block", target: "abc", within: 0, operation: "append", content: "x" }, + }, + { + name: "within on a frontmatter target", + instruction: { targetType: "frontmatter", target: "a", within: 0, operation: "replace", value: "v" }, + }, + { + name: "a non-integer within", + instruction: { targetType: "heading", target: ["A"], within: 0.5, operation: "append", content: "x" }, + }, + { + name: "within with marker scope", + instruction: { targetType: "heading", target: ["A"], within: 0, operation: "replace", scope: "marker", content: "x" }, + }, + { + name: "within with parent scope", + instruction: { + targetType: "heading", + target: ["A"], + within: 0, + operation: "replace", + scope: "parent", + destination: { parent: null, place: "last" }, + }, + }, + { + name: "a within replace @ markerAndContent (content covers it)", + instruction: { + targetType: "heading", + target: ["A"], + within: 0, + operation: "replace", + scope: "markerAndContent", + content: "x", + }, + }, + { + name: "a within delete @ markerAndContent (content covers it)", + instruction: { + targetType: "heading", + target: ["A"], + within: 0, + operation: "delete", + scope: "markerAndContent", + }, + }, + { + name: "within combined with createTargetIfMissing", + instruction: { + targetType: "heading", + target: ["A"], + within: 0, + operation: "append", + content: "x", + createTargetIfMissing: true, + }, + }, ]; test.each(invalid)("rejects $name", ({ instruction }) => { From dd435cf0cbfb8b0d5ad3fe0c3fc0f4cb36db4717 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 13:00:31 -0500 Subject: [PATCH 61/73] Document the within field across README, overview, and how-to The whitespace-contract section previously named a ^id block reference as the only way to edit inline within an existing block; it now presents both escape hatches and adds a "Positional block edits" section covering the index semantics (0-based, negative from the end, isolated ^id lines not counted), the literal-splice contract, sibling inserts via markerAndContent, and the two known footguns (a literal append after an inline ^id un-marks it; deleting a block annotated by an isolated ^id orphans the marker line). The how-to gains a "Continue an existing block" recipe, and the overview points at within from the whitespace paragraph. Co-Authored-By: Claude Fable 5 --- README.md | 28 +++++++++++++++++++++++++++- pages/how_to.md | 16 ++++++++++++++++ pages/overview.md | 2 +- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8796b33..a19faf5 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,33 @@ Where no separator is owed, none is added — a heading line is self-delimiting, - The blank line between a heading and its body is kept in place: `replace` swaps the body beneath it and `prepend` inserts below it. A document written flush (`# One\nbody of one\n`) keeps its flush style — `replace` gives `# One\nX\n` — and replacing a body with its own text is byte-identity in either style. - Writing into an empty section lands flush under its heading (`# E\nX\n`), with the section's existing trailing gap serving as the separator below. -One consequence worth knowing: a `content`-scope `append`/`prepend` always begins a new block — it can never continue an existing paragraph. To edit inline within a paragraph, target it via a block reference (`^id`), where content is spliced literally and you own the joint. +One consequence worth knowing: a `content`-scope `append`/`prepend` always begins a new block — it can never continue an existing paragraph. To edit inline *within* an existing block, address the block itself, which puts you on the literal-splice path where content is spliced exactly as given and you own the joint. There are two ways to address one: + +- Target it via a block reference (`^id`), if it has one. +- Add `within: ` to a heading instruction to pick one of the section's top-level body blocks by position — no `^id` required. + +### Positional block edits: `within` + +`within` refines a heading target to the Nth top-level block of the section's *direct* body (paragraphs, lists, tables, code fences, …), counted from 0 in document order; a negative index counts from the end. Isolated `^id` marker lines are not counted, so indices match the rendered blocks you see. Extend the last list of a section: + +```typescript +patch(document, { + targetType: "heading", + target: ["Log"], + within: -1, + operation: "append", + content: "\n- new item", +}); +``` + +With the default `content` scope the four operations act on the block itself: `replace`/`prepend`/`append` splice literally (that leading `\n` above is yours to write — `append` without it continues the block's last line), and `delete` removes the block along with its separator. With `scope: "markerAndContent"`, `prepend`/`append` instead insert your content as a *new* block immediately before/after the addressed one, with the usual library-owned separators. + +Two footguns to know about: + +- A literal `append` to a block whose last line ends with an inline `^id` lands *after* the marker, un-marking it — prefer the `^id` block target for blocks that carry one. +- Deleting a block that an isolated `^id` line annotates leaves the marker line behind, dangling. + +Because indices are positional, they are meant for single-request use: read the section (or its map), count its rendered blocks, and pair the edit with `ifMatch` from the same read so a concurrent change fails the patch instead of landing on the wrong block. > This contract intentionally reverses commit `4f84d89`, which documented the earlier "spliced verbatim / a leading `\n` buys the blank line" engine behavior rather than fixing it. That behavior contradicted the 2.0 design principle that the library owns whitespace, and preserved (in mutated form) the 1.x failure mode where a caller forgetting newline bookkeeping merges paragraphs. diff --git a/pages/how_to.md b/pages/how_to.md index cd7aae1..d884ee4 100644 --- a/pages/how_to.md +++ b/pages/how_to.md @@ -38,6 +38,22 @@ The heading target is an array of heading texts from the top level down, so a he Note that the heading line itself is not part of the `content` scope. When you `replace` a heading's content, supply only the body — including the heading line would duplicate it. +# Continue an existing block + +A plain `append` always starts a *new* block. To extend a block that is already there — add an item to a list, continue a paragraph — address the block positionally with `within` and splice into it literally: + +```typescript +patch(myDocument, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], + within: -1, // the section's last body block; 0 is the first + operation: "append", + content: "\n- Send the report", +}); +``` + +On a `within`-addressed block you own the joint: the leading `\n` above is what makes the text a new list item rather than a continuation of the last one. `replace`, `prepend`, and `delete` act on the same block; `scope: "markerAndContent"` with `prepend`/`append` inserts a new block beside it instead. Indices count the section's rendered top-level blocks (isolated `^id` lines don't count), so read the section first and pair the edit with `ifMatch` from the same read. + # Rename a heading Use the `marker` scope, which addresses the label rather than the body. Supply just the text; the engine preserves the level: diff --git a/pages/overview.md b/pages/overview.md index 99d707d..08032e5 100644 --- a/pages/overview.md +++ b/pages/overview.md @@ -67,7 +67,7 @@ I discovered a thing - Caught the flight home ``` -Whitespace is the library's job, not yours: content is trimmed to canonical form and the engine supplies the blank-line separators that keep your content its own block, so leading or trailing newlines in `content` change nothing. See the README for the full whitespace rules. +Whitespace is the library's job, not yours: content is trimmed to canonical form and the engine supplies the blank-line separators that keep your content its own block, so leading or trailing newlines in `content` change nothing. When you *do* want to continue an existing block rather than start a new one — extend a list, complete a sentence — add `within: ` to address one of the section's body blocks positionally and splice into it literally, no block reference required. See the README for the full whitespace rules and the `within` contract. See {@link Reference.patch} for the full instruction shape, {@link Reference.readTarget} for the read-side mirror of the same addressing, and {@link Reference.projectMap} for discovering what a document has to target. From 990d3ba8e03e0e801797968aebc179301759258c Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 14:52:12 -0500 Subject: [PATCH 62/73] Capture the Obsidian golden for the body-children fixture Captured from live Obsidian via the temporary REST route described as Method B in the conformance README. The golden confirms the two rules bodyChildren encodes: an isolated ^id marker line has no entry in Obsidian's section cache (the ^listref line is absent), and an inline ^id sits inside its block's span (inline1 spans its whole paragraph). Co-Authored-By: Claude Fable 5 --- .../conformance/body-children.obsidian.json | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/tests/conformance/body-children.obsidian.json diff --git a/src/tests/conformance/body-children.obsidian.json b/src/tests/conformance/body-children.obsidian.json new file mode 100644 index 0000000..ef0b5bc --- /dev/null +++ b/src/tests/conformance/body-children.obsidian.json @@ -0,0 +1,94 @@ +{ + "headings": [ + { + "level": 1, + "text": "Kinds", + "start": 0, + "end": 7 + }, + { + "level": 1, + "text": "Empty", + "start": 126, + "end": 133 + }, + { + "level": 1, + "text": "After", + "start": 135, + "end": 142 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 7 + }, + { + "type": "paragraph", + "start": 9, + "end": 25 + }, + { + "type": "paragraph", + "start": 27, + "end": 61 + }, + { + "type": "list", + "start": 63, + "end": 74 + }, + { + "type": "thematicBreak", + "start": 86, + "end": 89 + }, + { + "type": "code", + "start": 91, + "end": 109 + }, + { + "type": "blockquote", + "start": 111, + "end": 124 + }, + { + "type": "heading", + "start": 126, + "end": 133 + }, + { + "type": "heading", + "start": 135, + "end": 142 + }, + { + "type": "paragraph", + "start": 144, + "end": 179 + } + ], + "blocks": { + "inline1": { + "start": 27, + "end": 61 + }, + "listref": { + "start": 63, + "end": 74 + } + }, + "listItems": [ + { + "start": 63, + "end": 68 + }, + { + "start": 69, + "end": 74 + } + ] +} From 86a70f361710171670f532422566047c3f56063a Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 15:15:38 -0500 Subject: [PATCH 63/73] Guarantee insertions begin at a line start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A moved section, a markerAndContent sibling insert, and a body write under a terminator-less heading all splice heading-led (or flush-joint) text at an offset the engine assumed was a line start. At the end of a document whose last line has no trailing newline that assumption fails, gluing the fragment onto the last line ('last line no newline## Moved'). blockEdit's padBefore=false side and moveSection's insertion now contribute the single line ending such a joint is missing, via a shared lineStartGap helper — the floor below ownedGaps' blank-line separator. Flush-joint policy is otherwise unchanged: no blank line is owed before a heading-led fragment or directly under a marker. Co-Authored-By: Claude Fable 5 --- src/engine.ts | 15 ++++++++++----- src/engine/structural.ts | 10 ++++++++-- src/tests/docs.whitespace.test.ts | 30 ++++++++++++++++++++++++++++++ src/tests/structural.test.ts | 17 +++++++++++++++++ src/text.ts | 17 +++++++++++++++++ 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 4a6aa39..c1e68a6 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -22,7 +22,7 @@ import { subtreeEnd, blockFullRange, } from "./ranges.js"; -import { toLineEnding, sectionFragment, ownedGaps, splice } from "./text.js"; +import { toLineEnding, sectionFragment, ownedGaps, lineStartGap, splice } from "./text.js"; import { rebaseHeadings } from "./levels.js"; import { structuralHeading, deleteBlock, consumeTrailingBlank } from "./engine/structural.js"; import { patchFrontmatter } from "./engine/frontmatter.js"; @@ -284,8 +284,11 @@ const contentEdit = ( * blank-line separators the library owes on the sides where the caller says a * separator is due (`padBefore`/`padAfter`). On a due side, `ownedGaps` * contributes only what is missing: an existing blank line or a document edge - * needs nothing. An empty fragment clears the range without separators - * (empty replacement is deletion; an empty insert is a no-op). + * needs nothing. A side owing no blank line still owes a *line start* — a + * flush joint against a terminator-less last line gets the single ending that + * keeps the fragment off that line. An empty fragment clears the range + * without separators (empty replacement is deletion; an empty insert is a + * no-op). */ const blockEdit = ( document: string, @@ -298,10 +301,12 @@ const blockEdit = ( return { range, text }; } const gaps = ownedGaps(document, range, model.lineEnding); + const before = pads.padBefore + ? gaps.before + : lineStartGap(document, range.start, model.lineEnding); return { range, - text: - (pads.padBefore ? gaps.before : "") + text + (pads.padAfter ? gaps.after : ""), + text: before + text + (pads.padAfter ? gaps.after : ""), }; }; diff --git a/src/engine/structural.ts b/src/engine/structural.ts index ec5644b..c7325b6 100644 --- a/src/engine/structural.ts +++ b/src/engine/structural.ts @@ -26,7 +26,7 @@ import { subtreeEnd, blockFullRange, } from "../ranges.js"; -import { relevelText, endWithSingleEol, splice } from "../text.js"; +import { relevelText, endWithSingleEol, lineStartGap, splice } from "../text.js"; import { HeadingInstruction, HeadingMoveInstruction, @@ -116,7 +116,13 @@ const moveSection = ( text: "", }; const at = childInsertOffset(model, newParent, instruction.destination.place); - const insertion: Edit = { range: { start: at, end: at }, text: movedText }; + // The moved subtree opens with its own heading marker, which owes no blank + // line — but it still must begin at a line start, which the destination + // (the end of a terminator-less last line) may not provide. + const insertion: Edit = { + range: { start: at, end: at }, + text: lineStartGap(document, at, model.lineEnding) + movedText, + }; return splice(document, [removal, insertion], releveled.warnings); }; diff --git a/src/tests/docs.whitespace.test.ts b/src/tests/docs.whitespace.test.ts index 24d51f8..f10185f 100644 --- a/src/tests/docs.whitespace.test.ts +++ b/src/tests/docs.whitespace.test.ts @@ -98,6 +98,36 @@ body of two ).toBe("# One\n\nbody of one\n\nX\n\n# Two\n\nbody of two\n"); }); + describe("a joint owing no blank line still owes a line start", () => { + // Flush-joint sides (a heading-led fragment, a body write directly under + // its marker) contribute no blank line — but when the document's last + // line has no terminator, splicing there verbatim would continue that + // line. The engine owes the single ending that keeps the fragment off it. + it("a sibling section appended at a terminator-less last line starts a fresh line", () => { + const doc = "# A\nbody no newline"; + expect( + patch(doc, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "markerAndContent", + content: "# B\nnew body", + }).document + ).toBe("# A\nbody no newline\n# B\nnew body\n"); + }); + + it("a body write under a terminator-less heading line starts a fresh line", () => { + expect( + patch("# A", { + targetType: "heading", + target: ["A"], + operation: "append", + content: "X", + }).document + ).toBe("# A\nX\n"); + }); + }); + it("writing into an empty section lands flush under its heading", () => { // A heading line is self-delimiting, so no separator is owed above; the // section's existing gap becomes the separator below. diff --git a/src/tests/structural.test.ts b/src/tests/structural.test.ts index 3f28fd1..186d4ab 100644 --- a/src/tests/structural.test.ts +++ b/src/tests/structural.test.ts @@ -148,6 +148,23 @@ describe("patch — move (replace @ parent)", () => { ).toThrow(EngineError); }); + test("moving beneath a parent whose last line has no terminator starts a fresh line", () => { + // The destination offset is the end of a document with no trailing + // newline; without the owed line start the moved marker would glue onto + // the parent's last body line ("…no newline## Move me"). + const doc = "# A\n\n## Move me\n\nbody\n\n# B\nlast line no newline"; + const result = patch(doc, { + targetType: "heading", + target: ["A", "Move me"], + operation: "replace", + scope: "parent", + destination: { parent: ["B"], place: "last" }, + }); + expect(result.document).toBe( + "# A\n\n# B\nlast line no newline\n## Move me\n\nbody\n" + ); + }); + test("an unresolvable new parent raises TargetNotFoundError", () => { expect(() => patch(DOC, { diff --git a/src/text.ts b/src/text.ts index 9a908f4..2d95df8 100644 --- a/src/text.ts +++ b/src/text.ts @@ -70,6 +70,23 @@ const eolsBefore = (document: string, at: number): number => { return count; }; +/** + * The line ending owed so an insertion at `at` begins at a line start: nothing + * at the document start or after an existing line ending, one ending when the + * preceding character is flush text (a document whose last line has no + * terminator). This is the floor below {@link ownedGaps}' blank-line + * separator: a joint that owes no *blank line* — a heading-led fragment, a + * body write directly under its marker — still must not begin mid-line. + */ +export const lineStartGap = ( + document: string, + at: number, + ending: LineEnding +): string => + at === 0 || document[at - 1] === "\n" || document[at - 1] === "\r" + ? "" + : ending; + /** * The blank-line separators the library owes around a block-level fragment * spliced over `range` (a collapsed range for a pure insertion). A non-empty From a3bdd0995d290fae588223b8da776a04452fd386 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 15:16:11 -0500 Subject: [PATCH 64/73] Start created heading chains at a line start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createHeading splices its heading chain at the ancestor's subtree end, which for the last section of a document whose final line has no trailing newline is not a line start — the created marker glued onto that line ('last line no newline# New Section'). Reuse lineStartGap to contribute the single line ending the joint is missing, the same guarantee moveSection and blockEdit gained in the previous commit. createBlock already handled this case with its own separator logic. Co-Authored-By: Claude Fable 5 --- src/engine/create.ts | 12 ++++++++++-- src/tests/create.test.ts | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/engine/create.ts b/src/engine/create.ts index 9264b0e..da72066 100644 --- a/src/engine/create.ts +++ b/src/engine/create.ts @@ -13,7 +13,7 @@ import { DocumentModel } from "../model.js"; import { resolveHeading } from "../resolve.js"; import { subtreeEnd } from "../ranges.js"; -import { sectionFragment, toLineEnding, splice } from "../text.js"; +import { sectionFragment, toLineEnding, lineStartGap, splice } from "../text.js"; import { HeadingInstruction, BlockInstruction, @@ -81,10 +81,18 @@ export const createHeading = ( } warnings.push(...body.warnings); + // The chain opens with a heading marker, which owes no blank line — but the + // insertion point (the end of a terminator-less last line) may not be a + // line start, and a heading cannot begin mid-line. const at = subtreeEnd(ancestor); return splice( document, - [{ range: { start: at, end: at }, text: parts.join("") }], + [ + { + range: { start: at, end: at }, + text: lineStartGap(document, at, model.lineEnding) + parts.join(""), + }, + ], warnings ); }; diff --git a/src/tests/create.test.ts b/src/tests/create.test.ts index 6780410..364e445 100644 --- a/src/tests/create.test.ts +++ b/src/tests/create.test.ts @@ -52,6 +52,22 @@ describe("createTargetIfMissing — headings", () => { expect(result.document).toContain("####### B\n"); }); + test("a heading created at a terminator-less last line starts a fresh line", () => { + // The insertion point is the end of a document with no trailing newline; + // without the owed line start the new marker would glue onto the last + // body line ("…no newline# New Section"). + const result = patch("# A\nlast line no newline", { + targetType: "heading", + target: ["New Section"], + operation: "append", + content: "hello", + createTargetIfMissing: true, + }); + expect(result.document).toBe( + "# A\nlast line no newline\n# New Section\nhello\n" + ); + }); + test("without createTargetIfMissing a missing heading still throws", () => { expect(() => patch("# A\na\n", { From d58f766b0b10fdb6e9cef7ae5190108028a29142 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 15:17:01 -0500 Subject: [PATCH 65/73] Re-level adjacent-destination moves in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A move whose destination offset coincides with the section's own span boundary — a self-anchored place, or a cross-parent move landing in the same textual spot, like a last child re-parented to precede the section that follows it — was still executed as remove-and-reinsert. The removal consumed the section's trailing blank-line separator while the reinserted text carried only a single terminator, so even a completely level-neutral 'move' mutated the document by eating the gap before the next heading. Such a move now replaces the subtree's own content range with the re-levelled text and touches nothing else: separators survive, and a level-neutral move is a byte-identical no-op. Co-Authored-By: Claude Fable 5 --- src/engine/structural.ts | 19 ++++++++++++++++++- src/tests/structural.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/engine/structural.ts b/src/engine/structural.ts index c7325b6..1f61fde 100644 --- a/src/engine/structural.ts +++ b/src/engine/structural.ts @@ -111,11 +111,28 @@ const moveSection = ( ); const movedText = endWithSingleEol(releveled.text, model.lineEnding); + const removalStart = subtreeStart(section); + const removalEnd = subtreeEnd(section); const removal: Edit = { - range: { start: subtreeStart(section), end: subtreeEnd(section) }, + range: { start: removalStart, end: removalEnd }, text: "", }; const at = childInsertOffset(model, newParent, instruction.destination.place); + + // A destination adjacent to the section's own span means it is not actually + // relocating (a self-anchored place, or a cross-parent move that lands in + // the same textual spot, e.g. a last child re-parented to precede the + // section that follows it). Remove-and-reinsert would consume the trailing + // separator the section already owns; re-level in place instead, leaving + // every surrounding byte untouched — a level-neutral move is then a + // byte-identical no-op. + if (at === removalStart || at === removalEnd) { + return splice( + document, + [{ range: source, text: releveled.text }], + releveled.warnings + ); + } // The moved subtree opens with its own heading marker, which owes no blank // line — but it still must begin at a line start, which the destination // (the end of a terminator-less last line) may not provide. diff --git a/src/tests/structural.test.ts b/src/tests/structural.test.ts index 186d4ab..dd6f1de 100644 --- a/src/tests/structural.test.ts +++ b/src/tests/structural.test.ts @@ -148,6 +148,41 @@ describe("patch — move (replace @ parent)", () => { ).toThrow(EngineError); }); + test("a level-neutral move to where the section already sits is a byte-identical no-op", () => { + const doc = "# P\n\n## S1\na\n\n## S2\nb\n"; + const afterSelf = patch(doc, { + targetType: "heading", + target: ["P", "S1"], + operation: "replace", + scope: "parent", + destination: { parent: ["P"], place: { after: ["P", "S1"] } }, + }); + expect(afterSelf.document).toBe(doc); + + const beforeFollower = patch(doc, { + targetType: "heading", + target: ["P", "S1"], + operation: "replace", + scope: "parent", + destination: { parent: ["P"], place: { before: ["P", "S2"] } }, + }); + expect(beforeFollower.document).toBe(doc); + }); + + test("a same-position move across parents re-levels in place, keeping separators", () => { + // B is A's last child and the destination (root, before C) is the same + // textual spot: only the levels change, and B's blank-line separator + // before C survives. + const result = patch("# A\na\n\n## B\nb\n\n# C\nc\n", { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "parent", + destination: { parent: null, place: { before: ["C"] } }, + }); + expect(result.document).toBe("# A\na\n\n# B\nb\n\n# C\nc\n"); + }); + test("moving beneath a parent whose last line has no terminator starts a fresh line", () => { // The destination offset is the end of a document with no trailing // newline; without the owed line start the moved marker would glue onto From d4f2684bfde1a0a06d1ffe50e41fa75a634bd2e2 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 15:45:35 -0500 Subject: [PATCH 66/73] Strip the pipeline's trailing newline from CLI marker payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A marker-scope payload is a single-line label — a heading text, a block id, or a frontmatter key — but the CLI passed stdin through byte-for-byte, so the newline every shell pipeline appends made 'echo New Name | mdpatch patch replace heading Old -s marker' fail against the schema's no-line-break rule (and block-id renames fail the id charset the same way). Marker payloads now drop one trailing newline, the same framing allowance the frontmatter branch already makes; body content is still passed through untouched. Co-Authored-By: Claude Fable 5 --- src/cli.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 0c6957d..59e3f9e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -206,6 +206,13 @@ program } catch { instruction.value = raw.replace(/\n$/, ""); } + } else if (options.scope === "marker") { + // A marker payload is a single-line label (heading text, block id, + // frontmatter key), so a shell pipeline's trailing newline is + // framing, not content — without this, `echo New Name | mdpatch + // patch replace heading Old -s marker` is rejected for the line + // break every pipeline appends. Body content keeps its bytes. + instruction.content = raw.replace(/\r?\n$/, ""); } else { instruction.content = raw; } From 6c59ff7d3f5e7955e24f913d7642c33282b9e0a4 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 16:30:22 -0500 Subject: [PATCH 67/73] Build frontmatter entries from the positioned YAML AST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildFrontmatter parsed the block once for values but located entries with a hand-rolled line scan that matched each line's raw key text against the parsed keys by string equality. Any key whose written form differs from its parsed form — a quoted "foo", a quoted key containing a colon, anything the regex mis-captured — silently produced no entry, making it invisible to the map and resolver; and because frontmatter writes re-serialize the block from the entry list, the next edit to any sibling key silently dropped it from the document. Entries now come from parseDocument's pair nodes, which carry the parsed key, the parsed value, and real source ranges in one place, so the written form can never disagree with the addressable form. Parse errors (including duplicate keys) surface through doc.errors as the same FrontmatterParseError as before, and a non-mapping block still yields no entries. Co-Authored-By: Claude Fable 5 --- src/model.ts | 73 +++++++++++++++++------------------ src/tests/frontmatter.test.ts | 54 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 37 deletions(-) diff --git a/src/model.ts b/src/model.ts index 52ec124..8759222 100644 --- a/src/model.ts +++ b/src/model.ts @@ -1,5 +1,5 @@ import * as marked from "marked"; -import { parse as parseYaml } from "yaml"; +import { isMap, isScalar, parseDocument } from "yaml"; import { createHash } from "crypto"; import { DocumentRange } from "./types.js"; @@ -521,49 +521,48 @@ const buildFrontmatter = ( return { entries: [], block: null }; } const block: DocumentRange = { start: 0, end: contentOffset }; - let parsed: unknown; - try { - parsed = parseYaml(frontmatterText.trim()); - } catch (e) { + + // Parse the inner YAML once as a positioned AST. Each pair node carries + // both the *parsed* key and real source ranges, so a quoted `"foo"`, a key + // containing a colon, or a numeric key all yield the same entry the values + // expose. (The hand-rolled line scan this replaces matched raw line text + // against parsed keys by string equality, so any key whose written form + // differed from its parsed form silently produced no entry — and, since + // frontmatter writes re-serialize from the entry list, was dropped from the + // document by the next edit.) + const doc = parseDocument(frontmatterText); + if (doc.errors.length > 0) { throw new FrontmatterParseError( - `Could not parse document frontmatter: ${(e as Error).message}` + `Could not parse document frontmatter: ${doc.errors[0].message}` ); } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (!isMap(doc.contents)) { + // Empty, or not a mapping (e.g. a bare list): nothing key-addressable. return { entries: [], block }; } - const parsedRecord = parsed as Record; - - // The inner YAML begins just past the opening delimiter and runs for - // `frontmatterText.length`; the closing `---` follows. Locate each - // top-level `key:` at a line start so callers get real ranges (used later - // for key-scoped splicing). - const openingLength = - /^---(?:\r\n|\r|\n)/.exec(document)?.[0].length ?? 4; - const innerStart = openingLength; - const innerEnd = innerStart + frontmatterText.length; - - const keyStarts: Array<{ key: string; start: number; colon: number }> = []; - let lineStart = innerStart; - for (const line of frontmatterText.split(/(?<=\n)/)) { - const keyMatch = /^([^\s:][^:]*):/.exec(line); - if (keyMatch && keyMatch[1].trim() in parsedRecord) { - keyStarts.push({ - key: keyMatch[1].trim(), - start: lineStart, - colon: lineStart + keyMatch[0].length, - }); - } - lineStart += line.length; - } - const entries: FrontmatterEntry[] = keyStarts.map((entry, idx) => { - const end = keyStarts[idx + 1]?.start ?? innerEnd; + // Node ranges are offsets into the inner YAML text, which begins just past + // the opening delimiter line. + const innerStart = /^---(?:\r\n|\r|\n)/.exec(document)?.[0].length ?? 4; + + const entries: FrontmatterEntry[] = doc.contents.items.map((item) => { + const keyNode = item.key; + const valueNode = item.value; + const key = String(isScalar(keyNode) ? keyNode.value : keyNode.toJSON()); + const value: unknown = valueNode ? valueNode.toJSON() : null; + const keyRange = keyNode.range; + const valueRange = valueNode?.range ?? keyRange; return { - key: entry.key, - value: parsedRecord[entry.key], - entryRange: { start: entry.start, end }, - valueRange: { start: entry.colon, end }, + key, + value, + entryRange: { + start: innerStart + keyRange[0], + end: innerStart + valueRange[1], + }, + valueRange: { + start: innerStart + (valueNode?.range?.[0] ?? keyRange[1]), + end: innerStart + valueRange[1], + }, }; }); diff --git a/src/tests/frontmatter.test.ts b/src/tests/frontmatter.test.ts index 1811dc7..4238f8a 100644 --- a/src/tests/frontmatter.test.ts +++ b/src/tests/frontmatter.test.ts @@ -170,6 +170,60 @@ describe("patch — frontmatter markerAndContent cells", () => { }); }); +describe("buildModel — keys whose written form differs from their parsed form", () => { + // Entries come from the positioned YAML AST, so a key is recognized by what + // it *parses to*, never by matching its raw line text. The line-scan this + // guards against silently produced no entry for such keys — and, since + // frontmatter writes re-serialize from the entry list, destroyed them. + const QUOTED = '---\n"a:b": 1\nother: 2\n---\nbody\n'; + + test("a quoted key containing a colon is listed and addressable", () => { + const model = buildModel(QUOTED); + expect(model.frontmatter.entries.map((e) => e.key)).toEqual([ + "a:b", + "other", + ]); + expect(readTarget(QUOTED, { targetType: "frontmatter", target: "a:b" })) + .toEqual({ kind: "frontmatter", value: 1 }); + }); + + test("a quoted key survives a write to a sibling key", () => { + const result = patch(QUOTED, { + targetType: "frontmatter", + target: "other", + operation: "replace", + value: 3, + }); + // The serializer may re-emit the key in plain style ("a:b:" parses to the + // same key); what matters is that the entry survives with its value. + expect(result.document).toBe("---\na:b: 1\nother: 3\n---\nbody\n"); + const after = buildModel(result.document); + expect(after.frontmatter.entries.map((e) => [e.key, e.value])).toEqual([ + ["a:b", 1], + ["other", 3], + ]); + }); + + test("a plainly quoted key is editable under its parsed name", () => { + const result = patch('---\n"foo": 1\n---\nbody\n', { + targetType: "frontmatter", + target: "foo", + operation: "replace", + value: 9, + }); + expect(result.document).toBe("---\nfoo: 9\n---\nbody\n"); + }); + + test("a numeric key round-trips in document order", () => { + const doc = "---\n2024: notes\nalpha: 1\n---\n"; + const model = buildModel(doc); + expect(model.frontmatter.entries.map((e) => e.key)).toEqual([ + "2024", + "alpha", + ]); + }); +}); + describe("buildModel — malformed frontmatter", () => { const colonInFrontmatter = fs.readFileSync( path.join(__dirname, "sample.frontmatter.colon-in-value.md"), From c34b0541a68c03641a3320b10acd2d2a87525f40 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 16:32:29 -0500 Subject: [PATCH 68/73] Add scope to readTarget, mirroring the patch scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH accepts marker and markerAndContent scopes, but there was no way to *see* those values first — and because heading levels are engine- normalized, assembling a markerAndContent payload by hand meant exactly the '#'-counting the design forbids (map key + content read + shift every level by one). readTarget now takes an optional scope (default content, unchanged) with one invariant tying reads to writes: read @ scope S, then replace @ scope S with the value unchanged, is a no-op. marker yields the raw label (heading text without the duplicate-disambiguation suffix a map key may carry, a block's bare id, a frontmatter key); markerAndContent yields the whole node (a heading subtree re-levelled to the parent's baseline so its own line reads '# Title', a block's full span with its ^id). The one documented deviation: frontmatter markerAndContent returns the entry as {key: value} — the insert-payload shape — since a frontmatter replace carries a plain value at either scope and a strict mirror would duplicate the content read. A within read remains content-only, matching its write cells. Co-Authored-By: Claude Fable 5 --- src/index.ts | 2 +- src/read.ts | 109 +++++++++++++++++++++++++++------- src/tests/read.test.ts | 130 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 218 insertions(+), 23 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8bee9ac..efe2512 100755 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ export type { export { projectMap, headingTreePaths } from "./projection.js"; export type { PublicMap, HeadingTree } from "./projection.js"; export { readTarget } from "./read.js"; -export type { ReadTarget, ReadResult } from "./read.js"; +export type { ReadTarget, ReadResult, ReadScope } from "./read.js"; export { EngineError, InvalidCellError, diff --git a/src/read.ts b/src/read.ts index e329199..9259160 100644 --- a/src/read.ts +++ b/src/read.ts @@ -1,24 +1,50 @@ /** * Targeted reads over the 2.0 model. The mirror image of {@link patch}: an * address (the same `(targetType, target)` pair a patch instruction carries) - * resolves to a node, and the node's addressable value comes back. Headings and - * blocks yield their content as a string; frontmatter yields the parsed value. + * resolves to a node, and the node's addressable value comes back. An optional + * `scope` mirrors the patch scopes, with one invariant tying the two together: + * *read @ scope S, then `replace` @ scope S with the value unchanged, is a + * no-op.* * - * A heading's content is de-levelled by the target's own level before it is - * returned, mirroring the baseline a `content`-scope write rebases *up* by (see - * `levels.ts`). Without this, a heading's content round-trips through a - * content-scope write at the wrong depth: reading section "# Overview"'s - * "## Details" child and writing it straight back would rebase it to "### Details". + * - `content` (the default): a heading's body de-levelled by the target's own + * level, a block's literal text, a frontmatter key's parsed value. + * - `marker`: the node's label — a heading's raw text (no `#`s, and no + * duplicate-disambiguation suffix), a block's bare id, a frontmatter key. + * - `markerAndContent`: the whole node — a heading's subtree re-levelled to + * its *parent's* baseline (its own heading line reads as `# Title`, exactly + * the shape a `markerAndContent` replace consumes), a block's full span + * including its `^id`, a frontmatter entry as a single-key object. + * + * The frontmatter `markerAndContent` read is the one deviation from the + * round-trip invariant: it returns the entry as `{key: value}` — the shape a + * `markerAndContent` *prepend/append* consumes — because on the write side a + * frontmatter `replace` carries a plain value at either scope, so a strict + * mirror would just duplicate the `content` read. + * + * A heading's `content` is de-levelled by the target's own level, mirroring + * the baseline a `content`-scope write rebases *up* by (see `levels.ts`). + * Without this, a heading's content round-trips through a content-scope write + * at the wrong depth: reading section "# Overview"'s "## Details" child and + * writing it straight back would rebase it to "### Details". */ import { buildModel } from "./model.js"; import { resolveTarget, Addressed } from "./resolve.js"; -import { headingContentRange, blockContentRange } from "./ranges.js"; +import { + headingContentRange, + blockContentRange, + blockFullRange, + subtreeContentRange, +} from "./ranges.js"; import { relevelText } from "./text.js"; -import { TargetNotFoundError } from "./instructions.js"; +import { InvalidInstructionError, TargetNotFoundError } from "./instructions.js"; -/** The address of a read: the same addressing subset a patch instruction uses. */ -export type ReadTarget = Addressed; +/** The scopes a read supports — the value-bearing subset of the patch scopes. */ +export type ReadScope = "content" | "marker" | "markerAndContent"; + +/** The address of a read: the addressing subset a patch instruction uses, + * plus an optional {@link ReadScope} (default `content`). */ +export type ReadTarget = Addressed & { scope?: ReadScope }; /** The result of {@link readTarget}: markdown text, or a parsed frontmatter value. */ export type ReadResult = @@ -26,13 +52,15 @@ export type ReadResult = | { kind: "frontmatter"; value: unknown }; /** - * Resolve `target` against `document` and return the addressed value. For a - * heading the content span is the whole section body (subsections included), - * matching {@link headingContentRange}; for a block it is the block's text; for - * frontmatter it is the parsed value of the key. Throws {@link TargetNotFoundError} - * when the address does not resolve. + * Resolve `target` against `document` and return the addressed value at the + * requested scope (default `content`). Throws {@link TargetNotFoundError} + * when the address does not resolve — or for a `marker` read of the markerless + * document root — and {@link InvalidInstructionError} for a `within` read at a + * non-`content` scope (a positional body block has no marker of its own; its + * `markerAndContent` cells are insert-only on the write side). */ export const readTarget = (document: string, target: ReadTarget): ReadResult => { + const scope: ReadScope = target.scope ?? "content"; const model = buildModel(document); const resolved = resolveTarget(model, target); if (!resolved) { @@ -42,9 +70,28 @@ export const readTarget = (document: string, target: ReadTarget): ReadResult => } switch (resolved.kind) { case "heading": { - const range = headingContentRange(resolved.section); + const section = resolved.section; + if (scope === "marker") { + if (!section.heading) { + throw new TargetNotFoundError( + "the document root has no marker to read" + ); + } + return { kind: "heading", content: section.heading.text }; + } + if (scope === "markerAndContent") { + const range = subtreeContentRange(section); + const raw = document.slice(range.start, range.end); + // The parent's level is the baseline a markerAndContent write rebases + // up by, so the section's own heading reads as `# Title`. + const baseline = section.parent?.heading?.level ?? 0; + const content = + baseline === 0 ? raw : relevelText(raw, -baseline, model.lineEnding).text; + return { kind: "heading", content }; + } + const range = headingContentRange(section); const raw = document.slice(range.start, range.end); - const baseline = resolved.section.heading?.level ?? 0; + const baseline = section.heading?.level ?? 0; // Baseline 0 (the document root) needs no releveling; skip it so a root // read stays a byte-identical slice rather than a normalize/reapply round // trip through relevelText. @@ -53,16 +100,36 @@ export const readTarget = (document: string, target: ReadTarget): ReadResult => return { kind: "heading", content }; } case "headingChild": { + if (scope !== "content") { + throw new InvalidInstructionError( + "a `within` read supports only the `content` scope: a positional body block has no marker of its own" + ); + } // A body child can contain no heading (headings are structure, not // children), so the slice is returned literally — no releveling. const { start, end } = resolved.child.range; return { kind: "heading", content: document.slice(start, end) }; } case "block": { - const range = blockContentRange(resolved.block); + const block = resolved.block; + if (scope === "marker") { + return { kind: "block", content: block.id }; + } + const range = + scope === "markerAndContent" + ? blockFullRange(block) + : blockContentRange(block); return { kind: "block", content: document.slice(range.start, range.end) }; } - case "frontmatter": - return { kind: "frontmatter", value: resolved.entry.value }; + case "frontmatter": { + const entry = resolved.entry; + if (scope === "marker") { + return { kind: "frontmatter", value: entry.key }; + } + if (scope === "markerAndContent") { + return { kind: "frontmatter", value: { [entry.key]: entry.value } }; + } + return { kind: "frontmatter", value: entry.value }; + } } }; diff --git a/src/tests/read.test.ts b/src/tests/read.test.ts index 6f17b98..82f0dfd 100644 --- a/src/tests/read.test.ts +++ b/src/tests/read.test.ts @@ -1,6 +1,6 @@ import { readTarget } from "../read"; import { patch } from "../engine"; -import { TargetNotFoundError } from "../instructions"; +import { InvalidInstructionError, TargetNotFoundError } from "../instructions"; const DOC = "---\n" + @@ -150,6 +150,134 @@ describe("readTarget", () => { ).toThrow(TargetNotFoundError); }); + describe("scoped reads", () => { + test("marker yields a heading's raw label text", () => { + expect( + readTarget(DOC, { + targetType: "heading", + target: ["Overview", "Details"], + scope: "marker", + }) + ).toEqual({ kind: "heading", content: "Details" }); + }); + + test("marker on the document root throws TargetNotFoundError", () => { + expect(() => + readTarget(DOC, { targetType: "heading", target: null, scope: "marker" }) + ).toThrow(TargetNotFoundError); + }); + + test("markerAndContent yields the subtree at the parent's baseline", () => { + const result = readTarget(DOC, { + targetType: "heading", + target: ["Overview", "Details"], + scope: "markerAndContent", + }); + // Details is h2 in the document; at its parent's (h1) baseline it reads + // as a top-level "# Details" — the shape a markerAndContent replace takes. + expect(result).toEqual({ + kind: "heading", + content: "# Details\n\nNested body.\n", + }); + }); + + test("a heading markerAndContent read round-trips through a markerAndContent replace", () => { + const doc = + "# Overview\n\nIntro.\n\n## Details\n\nNested.\n\n### Sub\n\nDeep.\n\n# Other\n\nElsewhere.\n"; + const result = readTarget(doc, { + targetType: "heading", + target: ["Overview", "Details"], + scope: "markerAndContent", + }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "heading", + target: ["Overview", "Details"], + operation: "replace", + scope: "markerAndContent", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + + test("a heading marker read round-trips through a marker replace", () => { + const doc = "# Overview\n\nIntro.\n"; + const result = readTarget(doc, { + targetType: "heading", + target: ["Overview"], + scope: "marker", + }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "heading", + target: ["Overview"], + operation: "replace", + scope: "marker", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + + test("marker yields a block's bare id; markerAndContent its full span", () => { + expect( + readTarget(DOC, { targetType: "block", target: "thesis", scope: "marker" }) + ).toEqual({ kind: "block", content: "thesis" }); + expect( + readTarget(DOC, { + targetType: "block", + target: "thesis", + scope: "markerAndContent", + }) + ).toEqual({ kind: "block", content: "The thesis. ^thesis" }); + }); + + test("a block markerAndContent read round-trips through a markerAndContent replace", () => { + const doc = "start\n\nThe thesis. ^thesis\n\nend\n"; + const result = readTarget(doc, { + targetType: "block", + target: "thesis", + scope: "markerAndContent", + }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "block", + target: "thesis", + operation: "replace", + scope: "markerAndContent", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + + test("marker yields a frontmatter key; markerAndContent the whole entry", () => { + expect( + readTarget(DOC, { + targetType: "frontmatter", + target: "title", + scope: "marker", + }) + ).toEqual({ kind: "frontmatter", value: "title" }); + expect( + readTarget(DOC, { + targetType: "frontmatter", + target: "tags", + scope: "markerAndContent", + }) + ).toEqual({ kind: "frontmatter", value: { tags: ["a", "b"] } }); + }); + + test("a within read at a non-content scope throws InvalidInstructionError", () => { + expect(() => + readTarget(DOC, { + targetType: "heading", + target: ["Overview"], + within: 0, + scope: "marker", + }) + ).toThrow(InvalidInstructionError); + }); + }); + test("an unresolvable target throws TargetNotFoundError", () => { expect(() => readTarget(DOC, { targetType: "heading", target: ["Nope"] }) From fd49efc97d99075855372e8f270d9e8b7095a207 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 16:33:20 -0500 Subject: [PATCH 69/73] Expose read scope through the CLI query command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit query gains -s/--scope, mirroring the flag patch already has, so a shell caller can fetch a heading's label or its whole subtree in the exact shape a marker / markerAndContent replace consumes. readTarget now also rejects an unrecognized scope string with a typed InvalidInstructionError instead of silently reading content — the CLI and HTTP layers pass scope through untyped. Co-Authored-By: Claude Fable 5 --- src/cli.ts | 7 +++++++ src/read.ts | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 59e3f9e..65a2233 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -296,6 +296,12 @@ program "Heading delimiter to use in place of '::'.", "::" ) + .option( + "-s, --scope ", + "Scope to read ('content', 'marker', 'markerAndContent'); defaults to " + + "'content'. Reads mirror writes: what a scope returns is what a " + + "'replace' at that scope consumes." + ) .argument( "", "Target type ('heading', 'block', 'frontmatter')" @@ -315,6 +321,7 @@ program result = readTarget(document, { targetType, target: parseTarget(targetType as TargetType, target, options.delimiter), + ...(options.scope !== undefined ? { scope: options.scope } : {}), } as Parameters[1]); } catch (e) { fail(e); diff --git a/src/read.ts b/src/read.ts index 9259160..3a5d1f7 100644 --- a/src/read.ts +++ b/src/read.ts @@ -59,8 +59,21 @@ export type ReadResult = * non-`content` scope (a positional body block has no marker of its own; its * `markerAndContent` cells are insert-only on the write side). */ +const READ_SCOPES: readonly ReadScope[] = [ + "content", + "marker", + "markerAndContent", +]; + export const readTarget = (document: string, target: ReadTarget): ReadResult => { const scope: ReadScope = target.scope ?? "content"; + if (!READ_SCOPES.includes(scope)) { + // Untyped callers (the CLI, HTTP layers) pass scope through as a string; + // an unrecognized one must not silently read as `content`. + throw new InvalidInstructionError( + `invalid read scope ${JSON.stringify(scope)}; expected one of ${READ_SCOPES.join(", ")}` + ); + } const model = buildModel(document); const resolved = resolveTarget(model, target); if (!resolved) { From aeb0b3b8c7c7ba1383601bdff66097bcbbd566ce Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 20:24:43 -0500 Subject: [PATCH 70/73] Document read scopes, table rows, and duplicate addressing in the README Bring the README back in sync with the 2.0 surface, which had drifted in three places and was silent on a fourth: - readTarget/query grew a scope parameter (marker, markerAndContent) that was undocumented; the new Read-scopes passage states the read/write invariant (read @ S then replace @ S is a no-op) and the frontmatter {key: value} deviation, and the query section now lists -s/--scope and -d. - Table-row writes (value: string[][] on a block target) were name-dropped in the CLI section but never explained, and the payload carrier table wrongly claimed value was frontmatter-only. A new "Table rows" section covers row semantics, cell escaping, and the ^id-only restriction. - Duplicate heading/block addressing via opaque marker suffixes in the map was entirely undocumented, as was the ReservedDuplicateMarkerError parse guard behind it. - The error table now lists all ten exported EngineError subclasses, and print-map's -d flag is mentioned. Also drops the 4f84d89 dev-log blockquote from the within section: readers of the published package cannot see that commit, so the note belongs in the project log, not the README. Co-Authored-By: Claude Fable 5 --- README.md | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a19faf5..f10e73f 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The payload rides in exactly one field, chosen by what it is: | Field | Type | Used for | |---|---|---| | `content` | `string` | Heading and block bodies/labels, and frontmatter key renames | -| `value` | `unknown` (JSON) | Frontmatter values | +| `value` | `unknown` (JSON) | Frontmatter values, and table rows (`string[][]`) | | `destination` | `ParentSpec` | Where a moved heading lands | Not every combination is meaningful. `prepend @ parent`, or any `parent` scope on a block or frontmatter target, is not part of the algebra and is rejected with an `InvalidCellError`. @@ -131,7 +131,23 @@ Two footguns to know about: Because indices are positional, they are meant for single-request use: read the section (or its map), count its rendered blocks, and pair the edit with `ifMatch` from the same read so a concurrent change fails the patch instead of landing on the wrong block. -> This contract intentionally reverses commit `4f84d89`, which documented the earlier "spliced verbatim / a leading `\n` buys the blank line" engine behavior rather than fixing it. That behavior contradicted the 2.0 design principle that the library owns whitespace, and preserved (in mutated form) the 1.x failure mode where a caller forgetting newline bookkeeping merges paragraphs. +### Table rows + +A `block` target whose block is a table supports structured row edits. Put a 2-D array of cell text in `value` instead of literal text in `content` — the carrier you choose decides which kind of write it is: + +```typescript +patch(document, { + targetType: "block", + target: "inventory", + operation: "append", + value: [ + ["widget", "4"], + ["sprocket", "1"], + ], +}); +``` + +`replace` swaps the table's body rows while keeping its header and separator lines; `prepend`/`append` insert rows before/after the existing body rows. Cells are *content*, not row source: a `|` in a cell is escaped for you, other markdown is passed through as written, and each row must match the table's column count. A cell containing a line break is rejected rather than silently split or rewritten as `
`. Structured row edits require a block target (`^id` on the table); a `within`-addressed table takes only literal-text edits. ### Frontmatter @@ -202,6 +218,8 @@ const map = projectMap(buildModel(document)); Each `headings` entry is an array whose length is that heading's level, so `["Meeting Notes", "Action Items"]` is two deep. Pass one straight back as a `target`. A `null` element marks a skipped level; `""` is a genuinely empty heading. +Duplicates are individually addressable. When two sibling headings share the same text (or two blocks share an id), the first occurrence keeps its plain text and each later occurrence's map entry carries an opaque, non-printable marker suffix. Copy such an entry verbatim from the map into your `target` — the suffix is made of reserved codepoints you are not meant to type or construct yourself. (A document whose own heading text already ends in the reserved sequence is rejected at parse time with `ReservedDuplicateMarkerError`, so a synthesized address can never collide with real text.) + `readTarget` is the mirror image of `patch` — the same `(targetType, target)` address, read instead of written: ```typescript @@ -214,6 +232,14 @@ readTarget(document, { targetType: "frontmatter", target: "tags" }); // { kind: "frontmatter", value: ["alpha"] } ``` +Reads take an optional `scope` mirroring the patch scopes, with one invariant tying the two together: **read at scope S, then `replace` at scope S with the value unchanged, is a no-op.** + +- `content` (the default) — the node's body: a heading's body de-levelled by the target's own level (so writing it back through a `content`-scope `replace` round-trips), a block's literal text, a frontmatter key's parsed value. +- `marker` — the label: a heading's raw text (no `#`s, no duplicate-marker suffix), a block's bare id, a frontmatter key. +- `markerAndContent` — the whole node, in exactly the shape a `markerAndContent` `replace` consumes: a heading's subtree re-levelled to its parent's baseline (its own heading line reads as `# Title`), a block's full span including its `^id`. The one deviation from the invariant is frontmatter, which reads as a `{key: value}` object — the shape a `markerAndContent` `prepend`/`append` takes, since a frontmatter `replace` carries a plain value at either scope. + +A `within` read supports only `content` — a positional body block has no marker of its own. + ### Optimistic concurrency Pass `ifMatch` with the `version` token from the map you planned against. If the document changed since, the patch throws `PreconditionFailedError` and nothing is modified — rebuild the map and retry: @@ -235,10 +261,17 @@ All failures extend `EngineError`: | Error | Raised when | |---|---| | `InvalidCellError` | The operation×scope combination is not part of the algebra | +| `InvalidInstructionError` | The instruction is malformed — a bad field, target shape, or payload carrier for an otherwise-valid cell | | `TargetNotFoundError` | The address does not resolve (and `createTargetIfMissing` was not set) | | `PreconditionFailedError` | The `ifMatch` version did not match | | `ContentPreexistsError` | `rejectIfContentPreexists` was set and the value was already there | | `MergeError` | A frontmatter merge hit a type mismatch | +| `FrontmatterParseError` | The frontmatter block is not parseable YAML | +| `FrontmatterKeyCollisionError` | A key rename or entry insert would create a duplicate key | +| `ReservedDuplicateMarkerError` | The source document's own text ends in the reserved duplicate-marker sequence | +| `NotATableError` | A table-row `value` addressed a block that is not a table | +| `TableColumnCountError` | A supplied row's cell count does not match the table's columns | +| `InvalidCellContentError` | A table cell's text cannot be written as a row (e.g. contains a line break) | ## CLI reference @@ -295,9 +328,11 @@ Read a target's content and write it to stdout (or a file with `-o`): markdown f mdpatch query [options] ``` +`-s, --scope` selects `content` (default), `marker`, or `markerAndContent`, mirroring the patch scopes — what a scope returns is what a `replace` at that scope consumes. See [Read scopes](#inspecting-a-document). `-d, --delimiter` overrides the `::` heading-path delimiter, as on `patch`. + ### `mdpatch print-map` -Show a document's addressable map — its `version` token (for `--if-match`), frontmatter fields, heading tree, and block ids — as JSON. With a regex, list only matching addresses, one `typeaddress` per line. +Show a document's addressable map — its `version` token (for `--if-match`), frontmatter fields, heading tree, and block ids — as JSON. With a regex, list only matching addresses, one `typeaddress` per line (`-d, --delimiter` overrides the `::` joining the heading paths). ``` mdpatch print-map [regex] From e4e6b0cf923bf3a67917372cdd6e864de5e895b9 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 21:07:03 -0500 Subject: [PATCH 71/73] Restructure the README top around a worked example and a Why section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README previously opened with the scope/operation model — reference material — before showing any payoff. It now opens with a before/after diff of a mid-document append (the same edit shown as a patch() call and as a one-line mdpatch command, both verified against the engine), followed by install and a Why section giving the four failure modes of naive markdown editing: structure-blind addressing, hand-spliced whitespace, heading-depth rewrites, and concurrent modification. The Why section closes with the agent/automation story and the Obsidian Local REST API proof line. The duplicate-headings changelog argument lives in the first Why bullet. Also adds demo.tape, a self-contained VHS script that regenerates the README demo GIF (npm run build && vhs demo.tape): it builds its fixture in a mktemp dir and drives the repo's own dist/cli.js. The rendered demo.gif is gitignored on purpose — it will be hosted externally (S3) rather than committed. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++-- demo.tape | 30 ++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 demo.tape diff --git a/.gitignore b/.gitignore index b4a8ea4..ab89b97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/* node_modules/* docs/* +demo.gif diff --git a/README.md b/README.md index f10e73f..6742905 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,41 @@ Make targeted, structure-aware edits to Markdown documents — without `sed`. -Instead of treating a document as a blob of text, `markdown-patch` understands its structure (headings, block references, frontmatter) and lets you edit a specific location within it. +Instead of treating a document as a blob of text, `markdown-patch` understands its structure (headings, block references, frontmatter) and lets you edit a specific location within it: -Available as both a **CLI tool** (`mdpatch`) and a **TypeScript/JavaScript library**. +```diff + # Weekly Sync + + ## Notes + + Kim walked through the Q3 timeline. + ++Decided: we ship on Thursday. ++ + ## Attendees + + - Adam + - Kim +``` + +The new paragraph lands inside the `Notes` section — not at the end of the file — and the blank lines around it are the engine's job, not yours. The edit is one instruction, with no line numbers and no regex: + +```typescript +import { patch } from "markdown-patch"; + +const { document } = patch(note, { + targetType: "heading", + target: ["Weekly Sync", "Notes"], + operation: "append", + content: "Decided: we ship on Thursday.", +}); +``` + +The same edit from a shell, with the bundled `mdpatch` CLI: + +```sh +echo "Decided: we ship on Thursday." | mdpatch patch append heading "Weekly Sync::Notes" notes.md +``` **API docs:** https://coddingtonbear.github.io/markdown-patch/ @@ -16,6 +48,17 @@ npm install markdown-patch The `mdpatch` binary is included and available after install. +## Why + +The obvious ways to edit Markdown programmatically all break on contact with real documents: + +- **Regex and line numbers can't see structure.** In a changelog, `### Fixed` appears under every single release: a pattern matches all of them, while the heading path `["Changelog", "Unreleased", "Fixed"]` names exactly one. And when even the text is ambiguous — two identical sibling headings — the document map hands you a distinct address for each occurrence. +- **Hand-spliced text gets the joints wrong.** One missing `\n` merges two paragraphs; one extra one splits a list. Here, [whitespace is library-owned](#whitespace-is-library-owned): the engine supplies the separators, and `"X"`, `"X\n"`, and `"\nX\n"` all produce the same document. +- **Pasted sections land at the wrong depth.** Splicing a `## Details` subtree under a `###` heading means rewriting every `#` in it. Here, [heading levels are relative](#relative-heading-levels) — content is rebased to fit where it lands. +- **The file may have changed under you.** Between reading a document and writing your edit, anything can happen. Pass [`ifMatch`](#optimistic-concurrency) and a stale patch fails cleanly instead of landing in the wrong place. + +These properties matter most when the editor isn't a person. An LLM agent maintaining a note shouldn't re-emit a 4,000-token file to add one paragraph: it can read the compact document map instead of the whole document, target one section, and append — cheaper, faster, and incapable of mangling the 3,900 tokens it had no business touching. `markdown-patch` is the editing engine behind [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api)'s PATCH endpoints and MCP tools, where exactly that kind of client is the norm. + ## The model Every edit is one **operation** applied to a **scope** of a **target** node. diff --git a/demo.tape b/demo.tape new file mode 100644 index 0000000..660e67e --- /dev/null +++ b/demo.tape @@ -0,0 +1,30 @@ +# Regenerate demo.gif (requires https://github.com/charmbracelet/vhs): +# npm run build && vhs demo.tape +Output demo.gif +Require git +Require node + +Set FontSize 20 +Set Width 1460 +Set Height 720 +Set Padding 16 +Set TypingSpeed 30ms +Set Theme "Jellybeans" + +Hide +Type `REPO="$PWD"; cd "$(mktemp -d)"; printf '# Weekly Sync\n\n## Notes\n\nKim walked through the Q3 timeline.\n\n## Attendees\n\n- Adam\n- Kim\n' > notes.md; git init -q; git add notes.md; git commit -qm baseline; mdpatch() { node "$REPO/dist/cli.js" "$@"; }; clear` +Enter +Sleep 3s +Show + +Type "cat notes.md" +Enter +Sleep 2.5s + +Type 'echo "Decided: we ship on Thursday." | mdpatch patch append heading "Weekly Sync::Notes" notes.md' +Enter +Sleep 2.5s + +Type "git --no-pager diff" +Enter +Sleep 5s From 94481350c72bdb80dbedada5b350690547ec72e5 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 21:09:31 -0500 Subject: [PATCH 72/73] Adding demo image. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6742905..ad0c30d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # markdown-patch +![](http://coddingtonbear-public.s3.amazonaws.com/github/markdown-patch/demo.gif) + Make targeted, structure-aware edits to Markdown documents — without `sed`. Instead of treating a document as a blob of text, `markdown-patch` understands its structure (headings, block references, frontmatter) and lets you edit a specific location within it: From fa6a76190b079045a3623845686520c5b01dfa58 Mon Sep 17 00:00:00 2001 From: Adam Coddington Date: Thu, 23 Jul 2026 21:17:13 -0500 Subject: [PATCH 73/73] Add a generated table of contents to the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README is ~370 lines and npm renders no navigation for it. The TOC lives between markdown-toc's markers and is regenerated idempotently with `npm run toc` after any heading change — it is not hand-maintained. (The tool's --no-firsth1 flag turned out to be both buggy — emitting "undefined" bullets — and unnecessary, since the default already starts at the h2 level here.) Also includes the hosted demo.gif reference at the top of the README. Co-Authored-By: Claude Fable 5 --- README.md | 26 ++ package-lock.json | 601 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 +- 3 files changed, 629 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ad0c30d..74f522c 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,32 @@ echo "Decided: we ship on Thursday." | mdpatch patch append heading "Weekly Sync **API docs:** https://coddingtonbear.github.io/markdown-patch/ +**Contents** + + + +- [Install](#install) +- [Why](#why) +- [The model](#the-model) +- [Library usage](#library-usage) + * [Relative heading levels](#relative-heading-levels) + * [Whitespace is library-owned](#whitespace-is-library-owned) + * [Positional block edits: `within`](#positional-block-edits-within) + * [Table rows](#table-rows) + * [Frontmatter](#frontmatter) + * [Renaming, deleting, and moving](#renaming-deleting-and-moving) + * [Inspecting a document](#inspecting-a-document) + * [Optimistic concurrency](#optimistic-concurrency) + * [Errors](#errors) +- [CLI reference](#cli-reference) + * [`mdpatch patch`](#mdpatch-patch) + * [`mdpatch apply`](#mdpatch-apply) + * [`mdpatch query`](#mdpatch-query) + * [`mdpatch print-map`](#mdpatch-print-map) +- [Migrating from 1.x](#migrating-from-1x) + + + ## Install ```sh diff --git a/package-lock.json b/package-lock.json index 2c06be3..74de601 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "markdown-patch", "version": "2.0.0", - "license": "ISC", + "license": "MIT", "dependencies": { "@tsconfig/node16": "^16.1.3", "commander": "^12.1.0", @@ -23,6 +23,7 @@ "@types/node": "^22.4.0", "http-server": "^14.1.1", "jest": "^29.7.0", + "markdown-toc": "^1.2.0", "prettier": "^3.3.3", "ts-jest": "^29.2.4", "ts-node": "^10.9.2", @@ -1331,6 +1332,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ansi-red": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-red/-/ansi-red-0.1.1.tgz", + "integrity": "sha512-ewaIr5y+9CUTGFwZfpECUbFlGcC0GCw1oqR9RI6h1gQCd9Aj2GxSckCnPsVJnmfMZbwFYE+leZGASgkWl06Jow==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-wrap": "0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1355,6 +1369,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansi-wrap": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", + "integrity": "sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -1389,6 +1413,16 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "dev": true }, + "node_modules/autolinker": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-0.28.1.tgz", + "integrity": "sha512-zQAFO1Dlsn69eXaO6+7YZc+v84aquQKbwpzCE3L0stj56ERn9hutFxPopViLjo9G+rWwjozRhgS5KJ25Xy19cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "gulp-header": "^1.7.1" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1725,6 +1759,21 @@ "node": ">= 0.12.0" } }, + "node_modules/coffee-script": { + "version": "1.12.7", + "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.12.7.tgz", + "integrity": "sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw==", + "deprecated": "CoffeeScript on NPM has moved to \"coffeescript\" (no hyphen)", + "dev": true, + "license": "MIT", + "bin": { + "cake": "bin/cake", + "coffee": "bin/coffee" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -1763,12 +1812,45 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-with-sourcemaps": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", + "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", + "dev": true, + "license": "ISC", + "dependencies": { + "source-map": "^0.6.1" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/corser": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", @@ -1901,6 +1983,16 @@ "node": ">=8" } }, + "node_modules/diacritics-map": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/diacritics-map/-/diacritics-map-0.1.0.tgz", + "integrity": "sha512-3omnDTYrGigU0i4cJjvaKwD52B8aoqyX/NEIkukFFkogBemsIbhSa1O414fpTp5nuszJG6lvQ5vBvDVNCbSsaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -2069,6 +2161,62 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-range": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", + "integrity": "sha512-AFASGfIlnIbkKPQwX1yHaDjFvh/1gyKJODme52V6IORh69uEYgZp0o9C+qsIGNVEiuuhQU0CSSl++Rlegg1qvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^2.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-range/node_modules/fill-range": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.4.tgz", + "integrity": "sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^2.1.0", + "isobject": "^2.0.0", + "randomatic": "^3.0.0", + "repeat-element": "^1.1.2", + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-range/node_modules/is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha512-QUzH43Gfb9+5yckcrSA0VBDwEtDUchrk4F6tfJZQuNzDJbEDB9cZNzSfXGQ1jqmdDY/kl41lUOWM9syA8z8jlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-range/node_modules/isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -2085,6 +2233,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2175,6 +2336,16 @@ } } }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2296,6 +2467,36 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/gray-matter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-2.1.1.tgz", + "integrity": "sha512-vbmvP1Fe/fxuT2QuLVcqb2BfK7upGhhbLIt9/owWEvPYrZZEkelLcq2HqzxosV+PQ67dUFLaAeNpH7C4hhICAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-red": "^0.1.1", + "coffee-script": "^1.12.4", + "extend-shallow": "^2.0.1", + "js-yaml": "^3.8.1", + "toml": "^2.3.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-header": { + "version": "1.8.12", + "resolved": "https://registry.npmjs.org/gulp-header/-/gulp-header-1.8.12.tgz", + "integrity": "sha512-lh9HLdb53sC7XIZOYzTXM4lFuXElv3EVkSDhsd7DoJBj7hm+Ni7D3qYbb+Rr8DuM8nRanBvkVO9d7askreXGnQ==", + "deprecated": "Removed event-stream from gulp-header", + "dev": true, + "license": "MIT", + "dependencies": { + "concat-with-sourcemaps": "*", + "lodash.template": "^4.4.0", + "through2": "^2.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2509,6 +2710,13 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, "node_modules/is-core-module": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.0.tgz", @@ -2524,6 +2732,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2551,6 +2769,19 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -2563,12 +2794,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -3521,6 +3769,19 @@ "node": ">=6" } }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -3530,6 +3791,19 @@ "node": ">=6" } }, + "node_modules/lazy-cache": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-2.0.2.tgz", + "integrity": "sha512-7vp2Acd2+Kz4XkzxGxaB1FWOi8KjWIWsgdfD5MCb86DWvlLqhRPM+d6Pro3iNEL5VT9mstz5hKAlcd+QR6H3aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "set-getter": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -3554,6 +3828,35 @@ "uc.micro": "^2.0.0" } }, + "node_modules/list-item": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/list-item/-/list-item-1.1.1.tgz", + "integrity": "sha512-S3D0WZ4J6hyM8o5SNKWaMYB1ALSacPZ2nHGEuCjmHZ+dc03gFeNZoNDcqfcnO4vDhTZmNrqrpYZCdXsRh22bzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "expand-range": "^1.8.1", + "extend-shallow": "^2.0.1", + "is-number": "^2.1.0", + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/list-item/node_modules/is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha512-QUzH43Gfb9+5yckcrSA0VBDwEtDUchrk4F6tfJZQuNzDJbEDB9cZNzSfXGQ1jqmdDY/kl41lUOWM9syA8z8jlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -3572,12 +3875,41 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash._reinterpolate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", + "integrity": "sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/lodash.template": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.18.1.tgz", + "integrity": "sha512-5urZrLnV/VD6zHK5KsVtZgt7H19v51mIzoS0aBNH8yp3I8tbswrEjOABOPY8m8uB7NuibubLrMX+Y0PXsU9X+w==", + "deprecated": "This package is deprecated. Use https://socket.dev/npm/package/eta instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash._reinterpolate": "^3.0.0", + "lodash.templatesettings": "^4.0.0" + } + }, + "node_modules/lodash.templatesettings": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz", + "integrity": "sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash._reinterpolate": "^3.0.0" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3658,6 +3990,43 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/markdown-link": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/markdown-link/-/markdown-link-0.1.1.tgz", + "integrity": "sha512-TurLymbyLyo+kAUUAV9ggR9EPcDjP/ctlv9QAFiqUH7c+t6FlsbivPo9OKTU8xdOx9oNd2drW/Fi5RRElQbUqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/markdown-toc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/markdown-toc/-/markdown-toc-1.2.0.tgz", + "integrity": "sha512-eOsq7EGd3asV0oBfmyqngeEIhrbkc7XVP63OwcJBIhH2EpG2PzFcbZdhy1jutXSlRBBVMNXHvMtSr5LAxSUvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "concat-stream": "^1.5.2", + "diacritics-map": "^0.1.0", + "gray-matter": "^2.1.0", + "lazy-cache": "^2.0.2", + "list-item": "^1.1.1", + "markdown-link": "^0.1.1", + "minimist": "^1.2.0", + "mixin-deep": "^1.1.3", + "object.pick": "^1.2.0", + "remarkable": "^1.7.1", + "repeat-string": "^1.6.1", + "strip-color": "^0.1.0" + }, + "bin": { + "markdown-toc": "cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/marked": { "version": "17.0.1", "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", @@ -3670,6 +4039,13 @@ "node": ">= 20" } }, + "node_modules/math-random": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz", + "integrity": "sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==", + "dev": true, + "license": "MIT" + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -3737,6 +4113,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-deep/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -3806,6 +4209,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4053,6 +4469,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -4106,12 +4529,100 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/randomatic": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz", + "integrity": "sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^4.0.0", + "kind-of": "^6.0.0", + "math-random": "^1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/randomatic/node_modules/is-number": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", + "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/randomatic/node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/remarkable": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/remarkable/-/remarkable-1.7.4.tgz", + "integrity": "sha512-e6NKUXgX95whv7IgddywbeN/ItCkWbISmc2DiqHJb0wTrqZIexqdco5b8Z3XZoo/48IdNVKM9ZCvTPJ4F5uvhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "autolinker": "~0.28.0" + }, + "bin": { + "remarkable": "bin/remarkable.js" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/repeat-element": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", + "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4218,6 +4729,19 @@ "node": ">= 0.4" } }, + "node_modules/set-getter": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/set-getter/-/set-getter-0.1.1.tgz", + "integrity": "sha512-9sVWOy+gthr+0G9DzqqLaYNA7+5OKkSmcqjL9cBpDEaZrr3ShQlyX2cZ/O/ozE41oxn/Tt0LGEM/w4Rub3A3gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-object-path": "^0.3.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4326,6 +4850,16 @@ "node": ">=10" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -4374,6 +4908,16 @@ "node": ">=8" } }, + "node_modules/strip-color": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/strip-color/-/strip-color-0.1.0.tgz", + "integrity": "sha512-p9LsUieSjWNNAxVCXLeilaDlmuUOrDS5/dF9znM1nZc7EGX5+zEFC0bEevsNIaldjlks+2jns5Siz6F9iK6jwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -4433,6 +4977,17 @@ "node": ">=8" } }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -4448,6 +5003,19 @@ "node": ">=4" } }, + "node_modules/to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4460,6 +5028,13 @@ "node": ">=8.0" } }, + "node_modules/toml": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/toml/-/toml-2.3.6.tgz", + "integrity": "sha512-gVweAectJU3ebq//Ferr2JUY4WKSDe5N+z0FvjDncLGyHmIDoxgY/2Ie4qfEIDm4IS7OA6Rmdm7pdEEdMcV/xQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-jest": { "version": "29.2.4", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.4.tgz", @@ -4590,6 +5165,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, "node_modules/typedoc": { "version": "0.26.6", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.26.6.tgz", @@ -4709,6 +5291,13 @@ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "dev": true }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -4811,6 +5400,16 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index a6b1e61..69898f0 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@types/node": "^22.4.0", "http-server": "^14.1.1", "jest": "^29.7.0", + "markdown-toc": "^1.2.0", "prettier": "^3.3.3", "ts-jest": "^29.2.4", "ts-node": "^10.9.2", @@ -36,7 +37,8 @@ "prepack": "rm -rf dist && npm run build", "test": "NODE_OPTIONS=--experimental-vm-modules jest", "docs": "typedoc", - "docs-serve": "http-server docs" + "docs-serve": "http-server docs", + "toc": "markdown-toc -i README.md" }, "bin": { "mdpatch": "./dist/cli.js"