Skip to content

Version 2.0#16

Merged
coddingtonbear merged 73 commits into
mainfrom
version_2.0
Jul 24, 2026
Merged

Version 2.0#16
coddingtonbear merged 73 commits into
mainfrom
version_2.0

Conversation

@coddingtonbear

Copy link
Copy Markdown
Owner

No description provided.

coddingtonbear and others added 21 commits July 19, 2026 10:05
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
coddingtonbear marked this pull request as draft July 19, 2026 23:04
coddingtonbear and others added 8 commits July 19, 2026 18:34
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>
coddingtonbear and others added 28 commits July 22, 2026 08:47
…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>
@coddingtonbear
coddingtonbear merged commit fa6a761 into main Jul 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant