Version 2.0#16
Merged
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
coddingtonbear
marked this pull request as draft
July 19, 2026 23:04
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ription 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…cription 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 `<br>` would invent markup the caller did not ask for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Y4vyHDvFCMPe2VRVp8H7B
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Y4vyHDvFCMPe2VRVp8H7B
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Y4vyHDvFCMPe2VRVp8H7B
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Y4vyHDvFCMPe2VRVp8H7B
…nciple 1 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The README is ~370 lines and npm renders no navigation for it. The TOC lives between markdown-toc's <!-- toc --> 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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.