diff --git a/.gitignore b/.gitignore index b4a8ea4..ab89b97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/* node_modules/* docs/* +demo.gif diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1ab65ba --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 Adam Coddington + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4779e1e..74f522c 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,73 @@ # markdown-patch +![](http://coddingtonbear-public.s3.amazonaws.com/github/markdown-patch/demo.gif) + Make targeted, structure-aware edits to Markdown documents — without `sed`. -Instead of treating a document as a blob of text, `markdown-patch` understands its structure (headings, block references, frontmatter) and lets you append, prepend, or replace content at a specific location within it. +Instead of treating a document as a blob of text, `markdown-patch` understands its structure (headings, block references, frontmatter) and lets you edit a specific location within it: + +```diff + # Weekly Sync + + ## Notes -Available as both a **CLI tool** (`mdpatch`) and a **TypeScript/JavaScript library**. + Kim walked through the Q3 timeline. + ++Decided: we ship on Thursday. ++ + ## Attendees + + - Adam + - Kim +``` + +The new paragraph lands inside the `Notes` section — not at the end of the file — and the blank lines around it are the engine's job, not yours. The edit is one instruction, with no line numbers and no regex: + +```typescript +import { patch } from "markdown-patch"; + +const { document } = patch(note, { + targetType: "heading", + target: ["Weekly Sync", "Notes"], + operation: "append", + content: "Decided: we ship on Thursday.", +}); +``` + +The same edit from a shell, with the bundled `mdpatch` CLI: + +```sh +echo "Decided: we ship on Thursday." | mdpatch patch append heading "Weekly Sync::Notes" notes.md +``` **API docs:** https://coddingtonbear.github.io/markdown-patch/ +**Contents** + + + +- [Install](#install) +- [Why](#why) +- [The model](#the-model) +- [Library usage](#library-usage) + * [Relative heading levels](#relative-heading-levels) + * [Whitespace is library-owned](#whitespace-is-library-owned) + * [Positional block edits: `within`](#positional-block-edits-within) + * [Table rows](#table-rows) + * [Frontmatter](#frontmatter) + * [Renaming, deleting, and moving](#renaming-deleting-and-moving) + * [Inspecting a document](#inspecting-a-document) + * [Optimistic concurrency](#optimistic-concurrency) + * [Errors](#errors) +- [CLI reference](#cli-reference) + * [`mdpatch patch`](#mdpatch-patch) + * [`mdpatch apply`](#mdpatch-apply) + * [`mdpatch query`](#mdpatch-query) + * [`mdpatch print-map`](#mdpatch-print-map) +- [Migrating from 1.x](#migrating-from-1x) + + + ## Install ```sh @@ -16,12 +76,46 @@ npm install markdown-patch The `mdpatch` binary is included and available after install. -## Quick start +## Why -Given a document `notes.md`: +The obvious ways to edit Markdown programmatically all break on contact with real documents: -```markdown ---- +- **Regex and line numbers can't see structure.** In a changelog, `### Fixed` appears under every single release: a pattern matches all of them, while the heading path `["Changelog", "Unreleased", "Fixed"]` names exactly one. And when even the text is ambiguous — two identical sibling headings — the document map hands you a distinct address for each occurrence. +- **Hand-spliced text gets the joints wrong.** One missing `\n` merges two paragraphs; one extra one splits a list. Here, [whitespace is library-owned](#whitespace-is-library-owned): the engine supplies the separators, and `"X"`, `"X\n"`, and `"\nX\n"` all produce the same document. +- **Pasted sections land at the wrong depth.** Splicing a `## Details` subtree under a `###` heading means rewriting every `#` in it. Here, [heading levels are relative](#relative-heading-levels) — content is rebased to fit where it lands. +- **The file may have changed under you.** Between reading a document and writing your edit, anything can happen. Pass [`ifMatch`](#optimistic-concurrency) and a stale patch fails cleanly instead of landing in the wrong place. + +These properties matter most when the editor isn't a person. An LLM agent maintaining a note shouldn't re-emit a 4,000-token file to add one paragraph: it can read the compact document map instead of the whole document, target one section, and append — cheaper, faster, and incapable of mangling the 3,900 tokens it had no business touching. `markdown-patch` is the editing engine behind [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api)'s PATCH endpoints and MCP tools, where exactly that kind of client is the norm. + +## The model + +Every edit is one **operation** applied to a **scope** of a **target** node. + +- **`targetType`** — `heading`, `block`, or `frontmatter`. +- **`target`** — for a heading, an array of heading texts from the top level down (`["Meeting Notes", "Action Items"]`), or `null`/`[]` for the document root; for a block, the bare id without `^`; for a frontmatter field, the key. +- **`operation`** — `replace`, `prepend`, `append`, or `delete`. +- **`scope`** (optional, defaults to `content`): + - `content` — the node's body. For a heading, that's its whole subtree *below* the heading line. + - `marker` — the label only: a heading line, a block `^id`, or a frontmatter key. `replace` renames it. + - `markerAndContent` — the marker *and* the body together: for a heading, its heading line plus everything beneath it. Unlike `content`, the heading line is inside the edited span, so a `replace` here rewrites the heading itself. `prepend`/`append` insert a *sibling* before/after it. + - `parent` — a heading's place in the tree. Valid only with `replace`, and carries a `destination` (a **move**). + +The payload rides in exactly one field, chosen by what it is: + +| Field | Type | Used for | +|---|---|---| +| `content` | `string` | Heading and block bodies/labels, and frontmatter key renames | +| `value` | `unknown` (JSON) | Frontmatter values, and table rows (`string[][]`) | +| `destination` | `ParentSpec` | Where a moved heading lands | + +Not every combination is meaningful. `prepend @ parent`, or any `parent` scope on a block or frontmatter target, is not part of the algebra and is rejected with an `InvalidCellError`. + +## Library usage + +```typescript +import { patch } from "markdown-patch"; + +const document = `--- status: in-progress --- @@ -30,138 +124,310 @@ status: in-progress ## Action Items - Follow up with design team +`; + +const { document: patched, warnings } = patch(document, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], + operation: "append", + content: "Decided: ship on Thursday.", +}); ``` -Append a new item under `Action Items`: +`patch` returns `{ document, warnings }` — it does not mutate its input. The appended text lands as its own paragraph, separated from the list above it by a library-supplied blank line; see [Whitespace is library-owned](#whitespace-is-library-owned). -```sh -echo "- Send the report" | mdpatch patch append heading "Meeting Notes::Action Items" notes.md -``` +### Relative heading levels -Replace the `status` frontmatter field: +Heading `#`-counts inside a `content` string are *relative* to the span being edited, so you never count `#`s yourself. Appending `# Notes from the call` to the content of the level-1 `Meeting Notes` writes it as `## Notes from the call` — a direct child: -```sh -echo '"done"' | mdpatch patch replace frontmatter status notes.md +```typescript +patch(document, { + targetType: "heading", + target: ["Meeting Notes"], + operation: "append", + content: "# Notes from the call\n\nSome detail.\n", +}); ``` -Not sure what targets exist in a document? Use `print-map`: +Under `markerAndContent` (or a sibling insert) the same content lands at the target's own level instead. Nesting inside your content is preserved as you wrote it, so replacing a `##` section with `# New\n\n## Child` yields `## New` and `### Child`. A level rebased past `######` (h6) is still written, but `warnings` will contain a `heading-depth-overflow` entry. -```sh -mdpatch print-map notes.md -``` +Because the heading line is part of the `markerAndContent` span, a `replace` whose content has *no* heading removes it — the section is dissolved into a plain paragraph. Include a leading `#` (at any depth; it is rebased for you) to keep it a heading. -## CLI reference +### Whitespace is library-owned -### `mdpatch patch` +Your `content` crosses the API in trimmed, canonical form: leading and trailing blank lines are stripped, and a non-empty write always ends with exactly one newline. `"X"`, `"X\n"`, `"\nX\n"`, and `"X\n\n"` all produce the same document — newlines at the edges of your content are not a channel for controlling layout, so there is nothing to get wrong. + +Blank-line separators are the engine's job. At any joint where your content faces body text, the engine supplies the blank line that keeps it a separate block. So given: -Apply a single patch operation. +```markdown +# One +body of one ``` -mdpatch patch [options] + +- `append` becomes a new block after the body → `# One\n\nbody of one\n\nX\n` +- `prepend` becomes a new block before the body → `# One\n\nX\n\nbody of one\n` +- `replace` swaps the body → `# One\n\nX\n` + +Where no separator is owed, none is added — a heading line is self-delimiting, and existing blank lines, gaps between sections, and document edges are preserved rather than rewritten: + +- The blank line between a heading and its body is kept in place: `replace` swaps the body beneath it and `prepend` inserts below it. A document written flush (`# One\nbody of one\n`) keeps its flush style — `replace` gives `# One\nX\n` — and replacing a body with its own text is byte-identity in either style. +- Writing into an empty section lands flush under its heading (`# E\nX\n`), with the section's existing trailing gap serving as the separator below. + +One consequence worth knowing: a `content`-scope `append`/`prepend` always begins a new block — it can never continue an existing paragraph. To edit inline *within* an existing block, address the block itself, which puts you on the literal-splice path where content is spliced exactly as given and you own the joint. There are two ways to address one: + +- Target it via a block reference (`^id`), if it has one. +- Add `within: ` to a heading instruction to pick one of the section's top-level body blocks by position — no `^id` required. + +### Positional block edits: `within` + +`within` refines a heading target to the Nth top-level block of the section's *direct* body (paragraphs, lists, tables, code fences, …), counted from 0 in document order; a negative index counts from the end. Isolated `^id` marker lines are not counted, so indices match the rendered blocks you see. Extend the last list of a section: + +```typescript +patch(document, { + targetType: "heading", + target: ["Log"], + within: -1, + operation: "append", + content: "\n- new item", +}); ``` -- `` — `append`, `prepend`, or `replace` -- `` — `heading`, `block`, or `frontmatter` -- `` — the target address (see below) -- `` — file to modify (patched in-place by default) +With the default `content` scope the four operations act on the block itself: `replace`/`prepend`/`append` splice literally (that leading `\n` above is yours to write — `append` without it continues the block's last line), and `delete` removes the block along with its separator. With `scope: "markerAndContent"`, `prepend`/`append` instead insert your content as a *new* block immediately before/after the addressed one, with the usual library-owned separators. -Options: +Two footguns to know about: -| Flag | Description | -|---|---| -| `-i, --input ` | Read content from a file instead of stdin | -| `-o, --output ` | Write result to a file instead of patching in-place; use `-` for stdout | -| `-d, --delimiter ` | Heading path delimiter (default: `::`) | +- A literal `append` to a block whose last line ends with an inline `^id` lands *after* the marker, un-marking it — prefer the `^id` block target for blocks that carry one. +- Deleting a block that an isolated `^id` line annotates leaves the marker line behind, dangling. -### `mdpatch apply` +Because indices are positional, they are meant for single-request use: read the section (or its map), count its rendered blocks, and pair the edit with `ifMatch` from the same read so a concurrent change fails the patch instead of landing on the wrong block. -Apply one or more patch instructions from a JSON patch file. +### Table rows + +A `block` target whose block is a table supports structured row edits. Put a 2-D array of cell text in `value` instead of literal text in `content` — the carrier you choose decides which kind of write it is: +```typescript +patch(document, { + targetType: "block", + target: "inventory", + operation: "append", + value: [ + ["widget", "4"], + ["sprocket", "1"], + ], +}); ``` -mdpatch apply [options] + +`replace` swaps the table's body rows while keeping its header and separator lines; `prepend`/`append` insert rows before/after the existing body rows. Cells are *content*, not row source: a `|` in a cell is escaped for you, other markdown is passed through as written, and each row must match the table's column count. A cell containing a line break is rejected rather than silently split or rewritten as `
`. Structured row edits require a block target (`^id` on the table); a `within`-addressed table takes only literal-text edits. + +### Frontmatter + +Frontmatter payloads are JSON, so they ride in `value`: + +```typescript +patch(document, { + targetType: "frontmatter", + target: "status", + operation: "replace", + value: "done", +}); ``` -The patch file should be a JSON object (single instruction) or JSON array (multiple instructions). Use `-` to read from stdin. +`prepend`/`append` merge rather than overwrite — list concat, dict merge, string concat. Appending `["beta"]` to a `tags` of `["alpha"]` yields `["alpha", "beta"]`. Add `createTargetIfMissing: true` to create a field that may not exist yet. -### `mdpatch query` +To rename a key, use `scope: "marker"` — the new key name is a string, so it rides in `content`, not `value`. -Extract the content of a specific target and write it to stdout (or a file). +### Renaming, deleting, and moving +Rename a heading with the `marker` scope. Supply just the new text — the heading keeps whatever level it had, so you never need to know its depth: + +```typescript +patch(document, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], + operation: "replace", + scope: "marker", + content: "Follow-ups", +}); ``` -mdpatch query [options] + +Do **not** include `#` characters here. They are not stripped — they become part of the heading text, so `"## Follow-ups"` renames the heading to the literal `## Follow-ups`. (The removed 1.x `applyPatch` required them; if you are migrating, drop them.) + +The same shape renames a block id (`targetType: "block"`, new id without `^`) or a frontmatter key (`targetType: "frontmatter"`, new key in `content`). + +`delete` empties the `content` scope, removes the whole subtree (`markerAndContent`), or dissolves just the heading line while keeping its body (`marker`). + +A move is `replace @ parent` with a `destination`: + +```typescript +patch(document, { + targetType: "heading", + target: ["A", "Details"], + operation: "replace", + scope: "parent", + destination: { parent: ["B"], place: "last" }, +}); ``` -### `mdpatch print-map` +`place` may be `"first"`, `"last"`, `{ before: }`, or `{ after: }`. Use `parent: null` to move to the document root. The section is re-levelled to fit its new home. + +### Inspecting a document -Show all patchable targets discovered in a document, useful for finding the right target address. +`buildModel` parses a document; `projectMap` projects it into the public map of what is addressable: +```typescript +import { buildModel, projectMap } from "markdown-patch"; + +const map = projectMap(buildModel(document)); +// { +// version: "c23234", +// frontmatterFields: ["status"], +// headings: [["Meeting Notes"], ["Meeting Notes", "Action Items"]], +// blocks: [] +// } ``` -mdpatch print-map [regex] + +Each `headings` entry is an array whose length is that heading's level, so `["Meeting Notes", "Action Items"]` is two deep. Pass one straight back as a `target`. A `null` element marks a skipped level; `""` is a genuinely empty heading. + +Duplicates are individually addressable. When two sibling headings share the same text (or two blocks share an id), the first occurrence keeps its plain text and each later occurrence's map entry carries an opaque, non-printable marker suffix. Copy such an entry verbatim from the map into your `target` — the suffix is made of reserved codepoints you are not meant to type or construct yourself. (A document whose own heading text already ends in the reserved sequence is rejected at parse time with `ReservedDuplicateMarkerError`, so a synthesized address can never collide with real text.) + +`readTarget` is the mirror image of `patch` — the same `(targetType, target)` address, read instead of written: + +```typescript +import { readTarget } from "markdown-patch"; + +readTarget(document, { targetType: "heading", target: ["Meeting Notes", "Action Items"] }); +// { kind: "heading", content: "\n- Follow up with design team\n" } + +readTarget(document, { targetType: "frontmatter", target: "tags" }); +// { kind: "frontmatter", value: ["alpha"] } ``` -## Targets +Reads take an optional `scope` mirroring the patch scopes, with one invariant tying the two together: **read at scope S, then `replace` at scope S with the value unchanged, is a no-op.** -### Headings +- `content` (the default) — the node's body: a heading's body de-levelled by the target's own level (so writing it back through a `content`-scope `replace` round-trips), a block's literal text, a frontmatter key's parsed value. +- `marker` — the label: a heading's raw text (no `#`s, no duplicate-marker suffix), a block's bare id, a frontmatter key. +- `markerAndContent` — the whole node, in exactly the shape a `markerAndContent` `replace` consumes: a heading's subtree re-levelled to its parent's baseline (its own heading line reads as `# Title`), a block's full span including its `^id`. The one deviation from the invariant is frontmatter, which reads as a `{key: value}` object — the shape a `markerAndContent` `prepend`/`append` takes, since a frontmatter `replace` carries a plain value at either scope. -Address a section by its heading path, delimited by `::` (or a custom delimiter). Nested headings use the full path: +A `within` read supports only `content` — a positional body block has no marker of its own. -```sh -# Target the top-level "Overview" section -mdpatch patch append heading "Overview" notes.md +### Optimistic concurrency -# Target a nested heading -mdpatch patch append heading "Meeting Notes::Action Items" notes.md +Pass `ifMatch` with the `version` token from the map you planned against. If the document changed since, the patch throws `PreconditionFailedError` and nothing is modified — rebuild the map and retry: + +```typescript +patch(document, { + targetType: "frontmatter", + target: "status", + operation: "replace", + value: "done", + ifMatch: map.version, +}); ``` -### Block references +### Errors -Address a paragraph, table, or other block by its Obsidian block ID (e.g. `^abc123`): +All failures extend `EngineError`: + +| Error | Raised when | +|---|---| +| `InvalidCellError` | The operation×scope combination is not part of the algebra | +| `InvalidInstructionError` | The instruction is malformed — a bad field, target shape, or payload carrier for an otherwise-valid cell | +| `TargetNotFoundError` | The address does not resolve (and `createTargetIfMissing` was not set) | +| `PreconditionFailedError` | The `ifMatch` version did not match | +| `ContentPreexistsError` | `rejectIfContentPreexists` was set and the value was already there | +| `MergeError` | A frontmatter merge hit a type mismatch | +| `FrontmatterParseError` | The frontmatter block is not parseable YAML | +| `FrontmatterKeyCollisionError` | A key rename or entry insert would create a duplicate key | +| `ReservedDuplicateMarkerError` | The source document's own text ends in the reserved duplicate-marker sequence | +| `NotATableError` | A table-row `value` addressed a block that is not a table | +| `TableColumnCountError` | A supplied row's cell count does not match the table's columns | +| `InvalidCellContentError` | A table cell's text cannot be written as a row (e.g. contains a line break) | + +## CLI reference + +The CLI drives the same engine as the library: `patch` is the quick flag-based form for common single edits, and `apply` takes full instruction JSON for everything the model can express (moves, table rows, `ifMatch` pipelines). + +### `mdpatch patch` + +Apply a single instruction built from flags. Content is read from stdin unless `--input` is given; `delete` takes no content. -```sh -echo "New row content" | mdpatch patch append block "abc123" notes.md ``` +mdpatch patch [options] +``` + +- `` — `append`, `prepend`, `replace`, or `delete` +- `` — `heading`, `block`, or `frontmatter` +- `` — the target address: a `::`-joined containment path for headings (`""` for the document root), a bare block id, or a frontmatter key +- `` — file to modify (patched in-place by default) -When the target block is a Markdown table and content type is `application/json`, rows can be appended or prepended as JSON arrays. +Options: -### Frontmatter fields +| Flag | Description | +|---|---| +| `-i, --input ` | Read content from a file instead of stdin | +| `-o, --output ` | Write result to a file instead of patching in-place; use `-` for stdout | +| `-d, --delimiter ` | Heading path delimiter (default: `::`) | +| `-s, --scope ` | `content` (default), `marker`, `markerAndContent`, or `parent` | +| `--if-match ` | Fail unless the document's version token matches (see `print-map`) | +| `--create-target-if-missing` | Create the target (and missing ancestors) when it does not exist | +| `--reject-if-content-preexists` | Fail instead of applying when the content is already present | -Address a YAML frontmatter key by name. Content is treated as JSON: +For a `frontmatter` target the payload is parsed as JSON, falling back to the raw string; for `--scope parent` (a move) the payload is the JSON `destination`, e.g. `{"parent": ["Archive"], "place": "last"}`. ```sh -# Set a scalar -echo '"done"' | mdpatch patch replace frontmatter status notes.md +echo "- Send the report" | mdpatch patch append heading "Meeting Notes::Action Items" notes.md +echo '["draft", "urgent"]' | mdpatch patch replace frontmatter tags notes.md +mdpatch patch delete block quote-1 notes.md -s markerAndContent +``` + +### `mdpatch apply` + +Apply one or more patch instructions from a JSON patch file. + +``` +mdpatch apply [options] +``` + +The patch file should be a JSON object (single instruction) or JSON array (multiple instructions, applied in order) in exactly the shape the library's `patch` accepts — see [The model](#the-model). Use `-` to read from stdin. + +### `mdpatch query` + +Read a target's content and write it to stdout (or a file with `-o`): markdown for headings and blocks, JSON for frontmatter values. -# Append to a list -echo '"new-tag"' | mdpatch patch append frontmatter tags notes.md +``` +mdpatch query [options] ``` -## Library usage +`-s, --scope` selects `content` (default), `marker`, or `markerAndContent`, mirroring the patch scopes — what a scope returns is what a `replace` at that scope consumes. See [Read scopes](#inspecting-a-document). `-d, --delimiter` overrides the `::` heading-path delimiter, as on `patch`. -```typescript -import { applyPatch, getDocumentMap } from "markdown-patch"; +### `mdpatch print-map` -const document = `# My Note\n\n## Tasks\n\n- Buy milk\n`; +Show a document's addressable map — its `version` token (for `--if-match`), frontmatter fields, heading tree, and block ids — as JSON. With a regex, list only matching addresses, one `typeaddress` per line (`-d, --delimiter` overrides the `::` joining the heading paths). -const patched = applyPatch(document, { - operation: "append", - targetType: "heading", - target: ["My Note", "Tasks"], - content: "- Write tests\n", -}); +``` +mdpatch print-map [regex] ``` -`getDocumentMap` parses a document and returns its structure — useful for inspecting what headings, blocks, and frontmatter fields are available before patching. +## Migrating from 1.x -### Patch instruction options +Version 2.0 removes the 1.x API: `applyPatch`, `getDocumentMap`, and the `PatchInstruction` types are gone. If you need the old behavior as-is, stay on `markdown-patch@1`. -| Option | Type | Description | -|---|---|---| -| `operation` | `"append" \| "prepend" \| "replace"` | What to do | -| `targetType` | `"heading" \| "block" \| "frontmatter"` | What to target | -| `target` | `string \| string[]` | Target address (array for heading paths) | -| `content` | `string` | Content to apply | -| `contentType` | `"text/markdown" \| "application/json"` | Defaults to `text/markdown` | -| `targetScope` | `"content" \| "marker" \| "markerAndContent"` | Heading and block only. Defaults to `"content"`. `"marker"` targets only the heading line or block ID (useful for renaming). `"markerAndContent"` targets the full range covering both. | -| `createTargetIfMissing` | `boolean` | Create the heading or block if it doesn't exist | -| `rejectIfContentPreexists` | `boolean` | Reject the patch (with `ContentAlreadyPreexistsInTarget`) if the supplied content is already present in the target. Ignored for `replace` operations. | -| `trimTargetWhitespace` | `boolean` | Trim whitespace from the target boundary before joining | +The 1.x API spread its addressing across a `::`-joined `target` string with a separate `targetDelimiter`, offered no `delete` operation, no moves, and no `version` token. To migrate, switch to `patch` and move each field across: + +| 1.x (`applyPatch`) | Current (`patch`) | +|---|---| +| `operation: "append"` | `operation: "append"` (now also `"delete"`) | +| `targetType: "heading"` | `targetType: "heading"` | +| `target: "A::B"` (+ `targetDelimiter`) | `target: ["A", "B"]` (a real array — no delimiter) | +| `targetScope: "content"` | `scope: "content"` (adds `"parent"` for moves) | +| `content: "..."` | `content: "..."` for headings/blocks, `value: ` for frontmatter | +| `createTargetIfMissing: true` | `createTargetIfMissing: true` | +| `rejectIfContentPreexists: true` | `rejectIfContentPreexists: true` | +| `trimTargetWhitespace` | *(dropped; the engine owns boundary whitespace)* | +| `getDocumentMap(doc)` | `projectMap(buildModel(doc))` | + +Two behavioral differences to watch for when migrating: + +- **Heading levels are now relative.** 1.x took the `#`s in your content literally; the current engine rebases them against the span being edited. +- **Heading `content` scope covers the whole subtree.** In 1.x it stopped at the next heading of any level. diff --git a/demo.tape b/demo.tape new file mode 100644 index 0000000..660e67e --- /dev/null +++ b/demo.tape @@ -0,0 +1,30 @@ +# Regenerate demo.gif (requires https://github.com/charmbracelet/vhs): +# npm run build && vhs demo.tape +Output demo.gif +Require git +Require node + +Set FontSize 20 +Set Width 1460 +Set Height 720 +Set Padding 16 +Set TypingSpeed 30ms +Set Theme "Jellybeans" + +Hide +Type `REPO="$PWD"; cd "$(mktemp -d)"; printf '# Weekly Sync\n\n## Notes\n\nKim walked through the Q3 timeline.\n\n## Attendees\n\n- Adam\n- Kim\n' > notes.md; git init -q; git add notes.md; git commit -qm baseline; mdpatch() { node "$REPO/dist/cli.js" "$@"; }; clear` +Enter +Sleep 3s +Show + +Type "cat notes.md" +Enter +Sleep 2.5s + +Type 'echo "Decided: we ship on Thursday." | mdpatch patch append heading "Weekly Sync::Notes" notes.md' +Enter +Sleep 2.5s + +Type "git --no-pager diff" +Enter +Sleep 5s diff --git a/package-lock.json b/package-lock.json index 35b23a8..74de601 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,34 +1,37 @@ { "name": "markdown-patch", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "markdown-patch", - "version": "1.0.0", - "license": "ISC", + "version": "2.0.0", + "license": "MIT", "dependencies": { "@tsconfig/node16": "^16.1.3", - "chalk": "^5.3.0", "commander": "^12.1.0", "marked": "^17.0.1", - "yaml": "^2.5.1" + "yaml": "^2.5.1", + "zod": "^3.25.76" }, "bin": { "mdpatch": "dist/cli.js" }, "devDependencies": { - "@types/commander": "^2.12.2", "@types/jest": "^29.5.12", "@types/node": "^22.4.0", "http-server": "^14.1.1", "jest": "^29.7.0", + "markdown-toc": "^1.2.0", "prettier": "^3.3.3", "ts-jest": "^29.2.4", "ts-node": "^10.9.2", "typedoc": "^0.26.6", "typescript": "^5.5.4" + }, + "engines": { + "node": ">=20" } }, "node_modules/@ampproject/remapping": { @@ -1202,16 +1205,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/commander": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@types/commander/-/commander-2.12.2.tgz", - "integrity": "sha512-0QEFiR8ljcHp9bAbWxecjVRuAMr16ivPiGOw6KFQBVrVd0RQIcM3xKdRisH2EDWgVWujiYtHwhSkSUoAAGzH7Q==", - "deprecated": "This is a stub types definition for commander (https://github.com/tj/commander.js). commander provides its own type definitions, so you don't need @types/commander installed!", - "dev": true, - "dependencies": { - "commander": "*" - } - }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1339,6 +1332,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ansi-red": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-red/-/ansi-red-0.1.1.tgz", + "integrity": "sha512-ewaIr5y+9CUTGFwZfpECUbFlGcC0GCw1oqR9RI6h1gQCd9Aj2GxSckCnPsVJnmfMZbwFYE+leZGASgkWl06Jow==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-wrap": "0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1363,6 +1369,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansi-wrap": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", + "integrity": "sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -1397,6 +1413,16 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "dev": true }, + "node_modules/autolinker": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-0.28.1.tgz", + "integrity": "sha512-zQAFO1Dlsn69eXaO6+7YZc+v84aquQKbwpzCE3L0stj56ERn9hutFxPopViLjo9G+rWwjozRhgS5KJ25Xy19cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "gulp-header": "^1.7.1" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1679,17 +1705,6 @@ } ] }, - "node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -1744,6 +1759,21 @@ "node": ">= 0.12.0" } }, + "node_modules/coffee-script": { + "version": "1.12.7", + "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.12.7.tgz", + "integrity": "sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw==", + "deprecated": "CoffeeScript on NPM has moved to \"coffeescript\" (no hyphen)", + "dev": true, + "license": "MIT", + "bin": { + "cake": "bin/cake", + "coffee": "bin/coffee" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -1782,12 +1812,45 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-with-sourcemaps": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", + "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", + "dev": true, + "license": "ISC", + "dependencies": { + "source-map": "^0.6.1" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/corser": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", @@ -1920,6 +1983,16 @@ "node": ">=8" } }, + "node_modules/diacritics-map": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/diacritics-map/-/diacritics-map-0.1.0.tgz", + "integrity": "sha512-3omnDTYrGigU0i4cJjvaKwD52B8aoqyX/NEIkukFFkogBemsIbhSa1O414fpTp5nuszJG6lvQ5vBvDVNCbSsaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -2088,6 +2161,62 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-range": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", + "integrity": "sha512-AFASGfIlnIbkKPQwX1yHaDjFvh/1gyKJODme52V6IORh69uEYgZp0o9C+qsIGNVEiuuhQU0CSSl++Rlegg1qvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^2.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-range/node_modules/fill-range": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.4.tgz", + "integrity": "sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^2.1.0", + "isobject": "^2.0.0", + "randomatic": "^3.0.0", + "repeat-element": "^1.1.2", + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-range/node_modules/is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha512-QUzH43Gfb9+5yckcrSA0VBDwEtDUchrk4F6tfJZQuNzDJbEDB9cZNzSfXGQ1jqmdDY/kl41lUOWM9syA8z8jlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-range/node_modules/isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -2104,6 +2233,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2194,6 +2336,16 @@ } } }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2315,6 +2467,36 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/gray-matter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-2.1.1.tgz", + "integrity": "sha512-vbmvP1Fe/fxuT2QuLVcqb2BfK7upGhhbLIt9/owWEvPYrZZEkelLcq2HqzxosV+PQ67dUFLaAeNpH7C4hhICAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-red": "^0.1.1", + "coffee-script": "^1.12.4", + "extend-shallow": "^2.0.1", + "js-yaml": "^3.8.1", + "toml": "^2.3.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-header": { + "version": "1.8.12", + "resolved": "https://registry.npmjs.org/gulp-header/-/gulp-header-1.8.12.tgz", + "integrity": "sha512-lh9HLdb53sC7XIZOYzTXM4lFuXElv3EVkSDhsd7DoJBj7hm+Ni7D3qYbb+Rr8DuM8nRanBvkVO9d7askreXGnQ==", + "deprecated": "Removed event-stream from gulp-header", + "dev": true, + "license": "MIT", + "dependencies": { + "concat-with-sourcemaps": "*", + "lodash.template": "^4.4.0", + "through2": "^2.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2528,6 +2710,13 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, "node_modules/is-core-module": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.0.tgz", @@ -2543,6 +2732,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2570,6 +2769,19 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -2582,12 +2794,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -3540,6 +3769,19 @@ "node": ">=6" } }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -3549,6 +3791,19 @@ "node": ">=6" } }, + "node_modules/lazy-cache": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-2.0.2.tgz", + "integrity": "sha512-7vp2Acd2+Kz4XkzxGxaB1FWOi8KjWIWsgdfD5MCb86DWvlLqhRPM+d6Pro3iNEL5VT9mstz5hKAlcd+QR6H3aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "set-getter": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -3573,6 +3828,35 @@ "uc.micro": "^2.0.0" } }, + "node_modules/list-item": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/list-item/-/list-item-1.1.1.tgz", + "integrity": "sha512-S3D0WZ4J6hyM8o5SNKWaMYB1ALSacPZ2nHGEuCjmHZ+dc03gFeNZoNDcqfcnO4vDhTZmNrqrpYZCdXsRh22bzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "expand-range": "^1.8.1", + "extend-shallow": "^2.0.1", + "is-number": "^2.1.0", + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/list-item/node_modules/is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha512-QUzH43Gfb9+5yckcrSA0VBDwEtDUchrk4F6tfJZQuNzDJbEDB9cZNzSfXGQ1jqmdDY/kl41lUOWM9syA8z8jlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -3591,12 +3875,41 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash._reinterpolate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", + "integrity": "sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/lodash.template": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.18.1.tgz", + "integrity": "sha512-5urZrLnV/VD6zHK5KsVtZgt7H19v51mIzoS0aBNH8yp3I8tbswrEjOABOPY8m8uB7NuibubLrMX+Y0PXsU9X+w==", + "deprecated": "This package is deprecated. Use https://socket.dev/npm/package/eta instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash._reinterpolate": "^3.0.0", + "lodash.templatesettings": "^4.0.0" + } + }, + "node_modules/lodash.templatesettings": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz", + "integrity": "sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash._reinterpolate": "^3.0.0" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3677,6 +3990,43 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/markdown-link": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/markdown-link/-/markdown-link-0.1.1.tgz", + "integrity": "sha512-TurLymbyLyo+kAUUAV9ggR9EPcDjP/ctlv9QAFiqUH7c+t6FlsbivPo9OKTU8xdOx9oNd2drW/Fi5RRElQbUqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/markdown-toc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/markdown-toc/-/markdown-toc-1.2.0.tgz", + "integrity": "sha512-eOsq7EGd3asV0oBfmyqngeEIhrbkc7XVP63OwcJBIhH2EpG2PzFcbZdhy1jutXSlRBBVMNXHvMtSr5LAxSUvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "concat-stream": "^1.5.2", + "diacritics-map": "^0.1.0", + "gray-matter": "^2.1.0", + "lazy-cache": "^2.0.2", + "list-item": "^1.1.1", + "markdown-link": "^0.1.1", + "minimist": "^1.2.0", + "mixin-deep": "^1.1.3", + "object.pick": "^1.2.0", + "remarkable": "^1.7.1", + "repeat-string": "^1.6.1", + "strip-color": "^0.1.0" + }, + "bin": { + "markdown-toc": "cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/marked": { "version": "17.0.1", "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", @@ -3689,6 +4039,13 @@ "node": ">= 20" } }, + "node_modules/math-random": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz", + "integrity": "sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==", + "dev": true, + "license": "MIT" + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -3756,6 +4113,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-deep/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -3825,6 +4209,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4072,6 +4469,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -4125,12 +4529,100 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/randomatic": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz", + "integrity": "sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^4.0.0", + "kind-of": "^6.0.0", + "math-random": "^1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/randomatic/node_modules/is-number": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", + "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/randomatic/node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/remarkable": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/remarkable/-/remarkable-1.7.4.tgz", + "integrity": "sha512-e6NKUXgX95whv7IgddywbeN/ItCkWbISmc2DiqHJb0wTrqZIexqdco5b8Z3XZoo/48IdNVKM9ZCvTPJ4F5uvhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "autolinker": "~0.28.0" + }, + "bin": { + "remarkable": "bin/remarkable.js" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/repeat-element": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", + "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4237,6 +4729,19 @@ "node": ">= 0.4" } }, + "node_modules/set-getter": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/set-getter/-/set-getter-0.1.1.tgz", + "integrity": "sha512-9sVWOy+gthr+0G9DzqqLaYNA7+5OKkSmcqjL9cBpDEaZrr3ShQlyX2cZ/O/ozE41oxn/Tt0LGEM/w4Rub3A3gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-object-path": "^0.3.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4345,6 +4850,16 @@ "node": ">=10" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -4393,6 +4908,16 @@ "node": ">=8" } }, + "node_modules/strip-color": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/strip-color/-/strip-color-0.1.0.tgz", + "integrity": "sha512-p9LsUieSjWNNAxVCXLeilaDlmuUOrDS5/dF9znM1nZc7EGX5+zEFC0bEevsNIaldjlks+2jns5Siz6F9iK6jwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -4452,6 +4977,17 @@ "node": ">=8" } }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -4467,6 +5003,19 @@ "node": ">=4" } }, + "node_modules/to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4479,6 +5028,13 @@ "node": ">=8.0" } }, + "node_modules/toml": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/toml/-/toml-2.3.6.tgz", + "integrity": "sha512-gVweAectJU3ebq//Ferr2JUY4WKSDe5N+z0FvjDncLGyHmIDoxgY/2Ie4qfEIDm4IS7OA6Rmdm7pdEEdMcV/xQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-jest": { "version": "29.2.4", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.4.tgz", @@ -4609,6 +5165,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, "node_modules/typedoc": { "version": "0.26.6", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.26.6.tgz", @@ -4728,6 +5291,13 @@ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "dev": true }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -4830,6 +5400,16 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -4903,6 +5483,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 7bad31a..69898f0 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "dependencies": { "@tsconfig/node16": "^16.1.3", - "chalk": "^5.3.0", "commander": "^12.1.0", "marked": "^17.0.1", - "yaml": "^2.5.1" + "yaml": "^2.5.1", + "zod": "^3.25.76" }, "devDependencies": { - "@types/commander": "^2.12.2", "@types/jest": "^29.5.12", "@types/node": "^22.4.0", "http-server": "^14.1.1", "jest": "^29.7.0", + "markdown-toc": "^1.2.0", "prettier": "^3.3.3", "ts-jest": "^29.2.4", "ts-node": "^10.9.2", @@ -19,20 +19,49 @@ "typescript": "^5.5.4" }, "name": "markdown-patch", - "version": "1.1.0", + "version": "2.0.0", "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "engines": { + "node": ">=20" + }, "scripts": { "build": "tsc", + "prepack": "rm -rf dist && npm run build", "test": "NODE_OPTIONS=--experimental-vm-modules jest", "docs": "typedoc", - "docs-serve": "http-server docs" + "docs-serve": "http-server docs", + "toc": "markdown-toc -i README.md" }, "bin": { "mdpatch": "./dist/cli.js" }, - "keywords": [], - "author": "", - "license": "ISC", + "repository": { + "type": "git", + "url": "git+https://github.com/coddingtonbear/markdown-patch.git" + }, + "bugs": { + "url": "https://github.com/coddingtonbear/markdown-patch/issues" + }, + "homepage": "https://github.com/coddingtonbear/markdown-patch#readme", + "keywords": [ + "markdown", + "patch", + "frontmatter", + "heading", + "block-reference", + "obsidian", + "cli" + ], + "author": "Adam Coddington ", + "license": "MIT", "description": "Change markdown documents by inserting or changing content relative to headings or other parts of a document's structure.", "files": [ "dist/", diff --git a/pages/how_to.md b/pages/how_to.md index 4b4ce8e..d884ee4 100644 --- a/pages/how_to.md +++ b/pages/how_to.md @@ -4,53 +4,166 @@ group: Documents category: Guides --- -# Using as a library +All examples below assume this document: -```ts -import {PatchInstruction, applyPatch} from "markdown-patch" +```typescript +const myDocument = `--- +status: in-progress +tags: + - alpha +--- + +# Meeting Notes + +## Action Items + +- Follow up with design team +`; +``` + +# Add content below a heading + +```typescript +import { patch } from "markdown-patch"; + +const { document } = patch(myDocument, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], + operation: "append", + content: "- Send the report\n", +}); +``` + +The heading target is an array of heading texts from the top level down, so a heading whose text contains `::` needs no escaping. `prepend` and `replace` take the same shape. + +Note that the heading line itself is not part of the `content` scope. When you `replace` a heading's content, supply only the body — including the heading line would duplicate it. + +# Continue an existing block + +A plain `append` always starts a *new* block. To extend a block that is already there — add an item to a list, continue a paragraph — address the block positionally with `within` and splice into it literally: + +```typescript +patch(myDocument, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], + within: -1, // the section's last body block; 0 is the first + operation: "append", + content: "\n- Send the report", +}); +``` + +On a `within`-addressed block you own the joint: the leading `\n` above is what makes the text a new list item rather than a continuation of the last one. `replace`, `prepend`, and `delete` act on the same block; `scope: "markerAndContent"` with `prepend`/`append` inserts a new block beside it instead. Indices count the section's rendered top-level blocks (isolated `^id` lines don't count), so read the section first and pair the edit with `ifMatch` from the same read. -const myDocument = ` -# Noise Floor +# Rename a heading -- Some content +Use the `marker` scope, which addresses the label rather than the body. Supply just the text; the engine preserves the level: -# Discoveries +```typescript +patch(myDocument, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], + operation: "replace", + scope: "marker", + content: "Follow-ups", +}); +``` + +# Delete a section + +`delete` means something different in each scope: it empties the body (`content`), removes the heading and everything under it (`markerAndContent`), or dissolves just the heading line while keeping its body in place (`marker`). + +```typescript +patch(myDocument, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], + operation: "delete", + scope: "markerAndContent", +}); +``` + +# Move a section + +A move is `replace` applied to the `parent` scope, carrying a `destination`: + +```typescript +patch(myDocument, { + targetType: "heading", + target: ["A", "Details"], + operation: "replace", + scope: "parent", + destination: { parent: ["B"], place: "last" }, +}); +``` + +`place` accepts `"first"`, `"last"`, `{ before: }`, or `{ after: }`; `parent: null` moves the section to the document root. The section is re-levelled to fit wherever it lands. -# Events +# Set or merge a frontmatter field -- Checked out of my hotel -- Caught the flight home +Frontmatter payloads are JSON rather than markdown, so they travel in `value`: -` +```typescript +patch(myDocument, { + targetType: "frontmatter", + target: "status", + operation: "replace", + value: "done", +}); +``` -const instruction: PatchInstruction { - operation: "append", - targetType: "heading", - target: "Discoveries", - content: "\n## My discovery\nI discovered a thing\n", -} +`append` and `prepend` merge instead of overwriting — list concat, dict merge, string concat — so appending `["beta"]` to `tags` yields `["alpha", "beta"]`: -console.log( - applyPatch(myDocument, instruction) -) +```typescript +patch(myDocument, { + targetType: "frontmatter", + target: "tags", + operation: "append", + value: ["beta"], + createTargetIfMissing: true, +}); ``` -and you'll see the output: +There is no "remove one item" operation. To drop a single list entry, read the field, filter it, and replace the whole value. + +# Find out what a document has to target -```markdown -# Noise Floor +```typescript +import { buildModel, projectMap } from "markdown-patch"; -- Some content +const map = projectMap(buildModel(myDocument)); +// { +// version: "c23234", +// frontmatterFields: ["status", "tags"], +// headings: [["Meeting Notes"], ["Meeting Notes", "Action Items"]], +// blocks: [] +// } +``` -# Discoveries +Each `headings` entry can be passed straight back as a `target`, and its length is the heading's level. -## My discovery -I discovered a thing +# Read a target instead of writing it -# Events +{@link Reference.readTarget} takes the same address a patch instruction carries: -- Checked out of my hotel -- Caught the flight home +```typescript +import { readTarget } from "markdown-patch"; +readTarget(myDocument, { + targetType: "heading", + target: ["Meeting Notes", "Action Items"], +}); +// { kind: "heading", content: "\n- Follow up with design team\n" } ``` +# Make an edit conditional on the document not having changed + +Pass the `version` from the map you planned against as `ifMatch`. If the document has moved on, the patch throws `PreconditionFailedError` and leaves it untouched: + +```typescript +patch(myDocument, { + targetType: "frontmatter", + target: "status", + operation: "replace", + value: "done", + ifMatch: map.version, +}); +``` diff --git a/pages/overview.md b/pages/overview.md index cbbe8ab..08032e5 100644 --- a/pages/overview.md +++ b/pages/overview.md @@ -19,7 +19,9 @@ You can install the package via `npm`: npm install markdown-patch ``` -And if you were to create a document named `document.md` with the following content: +Every edit is one **operation** (`replace`, `prepend`, `append`, `delete`) applied to a **scope** (`content`, `marker`, `markerAndContent`, `parent`) of a **target** node — a heading, a block reference, or a frontmatter field. + +Given a document named `document.md`: ```markdown # Noise Floor @@ -32,21 +34,22 @@ And if you were to create a document named `document.md` with the following cont - Checked out of my hotel - Caught the flight home - ``` -Then you can use the `patch` or `apply` subcommands to alter the document. For example, the following will add a new heading below the heading "Discoveries": - -```bash -mdpatch patch append heading Discoveries ./document.md +You can add a subsection below "Discoveries" like so: -## My discovery -I discovered a thing +```typescript +import { patch } from "markdown-patch"; - +const { document: patched } = patch(document, { + targetType: "heading", + target: ["Discoveries"], + operation: "append", + content: "# My discovery\n\nI discovered a thing", +}); ``` -Your final document will then look like: +Note that the content says `#`, not `##`. Heading levels inside a `content` string are *relative* to the span being edited, so a single `#` becomes a direct child of the target and you never have to count `#`s to match the surrounding document. The result: ```markdown # Noise Floor @@ -54,15 +57,18 @@ Your final document will then look like: - Some content # Discoveries - ## My discovery + I discovered a thing # Events - Checked out of my hotel - Caught the flight home - ``` -See `--help` for more insight into what commands are available. +Whitespace is the library's job, not yours: content is trimmed to canonical form and the engine supplies the blank-line separators that keep your content its own block, so leading or trailing newlines in `content` change nothing. When you *do* want to continue an existing block rather than start a new one — extend a list, complete a sentence — add `within: ` to address one of the section's body blocks positionally and splice into it literally, no block reference required. See the README for the full whitespace rules and the `within` contract. + +See {@link Reference.patch} for the full instruction shape, {@link Reference.readTarget} for the read-side mirror of the same addressing, and {@link Reference.projectMap} for discovering what a document has to target. + +> **Note:** the 1.x API (`applyPatch` and `getDocumentMap`) was removed in 2.0; see the README's "Migrating from 1.x" section for the migration table. diff --git a/src/cli.ts b/src/cli.ts index 7a3609e..65a2233 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,16 @@ #!/usr/bin/env node import { Command } from "commander"; import fs from "fs/promises"; -import { getDocumentMap } from "./map.js"; -import { printMap } from "./debug.js"; -import { PatchInstruction, PatchOperation, PatchTargetType } from "./types.js"; -import { applyPatch } from "./patch.js"; +import { patch } from "./engine.js"; +import { buildModel } from "./model.js"; +import { projectMap, headingTreePaths } from "./projection.js"; +import { readTarget } from "./read.js"; +import { + EngineError, + InstructionInput, + PatchResult, + TargetType, +} from "./instructions.js"; import packageJson from "../package.json"; async function readStdin(): Promise { @@ -16,9 +22,47 @@ async function readStdin(): Promise { }); } +/** Print engine failures as one clean line rather than a stack trace. */ +function fail(e: unknown): never { + if (e instanceof EngineError) { + console.error(`${e.constructor.name}: ${e.message}`); + process.exit(1); + } + throw e; +} + +function printWarnings(result: PatchResult): void { + for (const warning of result.warnings) { + console.error(`warning (${warning.code}): ${warning.message}`); + } +} + +async function writeResult( + document: string, + output: string | undefined, + fallbackPath: string +): Promise { + if (output === "-") { + process.stdout.write(document); + } else { + await fs.writeFile(output ? output : fallbackPath, document); + } +} + +/** A heading target is a delimiter-joined containment path; `''` is the root. */ +function parseTarget( + targetType: TargetType, + target: string, + delimiter: string +): string | string[] | null { + if (targetType !== "heading") { + return target; + } + return target === "" ? null : target.split(delimiter); +} + const program = new Command(); -// Configure the CLI program .name(Object.keys(packageJson.bin)[0]) .description(packageJson.description) @@ -26,20 +70,55 @@ program program .command("print-map") - .argument("", "filepath to show identified patchable paths for") + .description( + "Print a document's addressable map: its version token, frontmatter " + + "fields, heading tree, and block ids." + ) + .argument("", "filepath to show identified patchable targets for") .argument( "[regex]", - "limit displayed matches to those matching the supplied regular expression" + "list only addresses matching the supplied regular expression, one per line" ) - .action(async (path: string, regex: string | undefined) => { + .option( + "-d, --delimiter ", + "Heading delimiter to use in place of '::'.", + "::" + ) + .action(async (path: string, regex: string | undefined, options) => { const document = await fs.readFile(path, "utf-8"); - const documentMap = getDocumentMap(document); + const map = projectMap(buildModel(document)); - printMap(document, documentMap, regex ? new RegExp(regex) : undefined); + if (regex === undefined) { + console.log(JSON.stringify(map, null, 2)); + return; + } + + const pattern = new RegExp(regex); + for (const headingPath of headingTreePaths(map.headings)) { + const joined = headingPath.join(options.delimiter); + if (pattern.test(joined)) { + console.log(`heading\t${joined}`); + } + } + for (const blockId of map.blocks) { + if (pattern.test(blockId)) { + console.log(`block\t${blockId}`); + } + } + for (const field of map.frontmatterFields) { + if (pattern.test(field)) { + console.log(`frontmatter\t${field}`); + } + } }); program .command("patch") + .description( + "Apply a single instruction built from flags; content is read from " + + "stdin unless --input is given. For the full instruction model " + + "(table rows, moves), use `apply`." + ) .option( "-i, --input ", "Path to content to insert; by default reads from stdin." @@ -53,132 +132,205 @@ program "Heading delimiter to use in place of '::'.", "::" ) - .argument("", "Operation to perform ('replace', 'append', etc.)") - .argument("", "Target type ('heading', 'block', etc.)") + .option( + "-s, --scope ", + "Scope to operate on ('content', 'marker', 'markerAndContent', 'parent'); defaults to 'content'." + ) + .option( + "--if-match ", + "Fail unless the document's version token matches (see `print-map`)." + ) + .option( + "--create-target-if-missing", + "Create the target (and any missing ancestors) when it does not exist." + ) + .option( + "--reject-if-content-preexists", + "Fail instead of applying when the content already appears in the target." + ) + .argument( + "", + "Operation to perform ('replace', 'prepend', 'append', 'delete')" + ) + .argument( + "", + "Target type ('heading', 'block', 'frontmatter')" + ) .argument( "", - "Target ('::'-delimited by default for Headings); see `mdpatch print-map ` for options)" + "Target ('::'-delimited containment path for headings, '' for the " + + "document root; a bare block id; a frontmatter key); see `mdpatch " + + "print-map ` for options" ) .argument("", "Path to document to apply patch to.") .action( async ( - operation: PatchOperation, - targetType: PatchTargetType, + operation: string, + targetType: string, target: string, documentPath: string, options ) => { - let content: string; - if (options.input) { - content = await fs.readFile(options.input, "utf-8"); - } else { - content = await readStdin(); + const instruction: Record = { + operation, + targetType, + target: parseTarget(targetType as TargetType, target, options.delimiter), + }; + if (options.scope !== undefined) { + instruction.scope = options.scope; + } + if (options.ifMatch !== undefined) { + instruction.ifMatch = options.ifMatch; + } + if (options.createTargetIfMissing) { + instruction.createTargetIfMissing = true; + } + if (options.rejectIfContentPreexists) { + instruction.rejectIfContentPreexists = true; } - const document = await fs.readFile(documentPath, "utf-8"); + // Delete carries no payload; everything else reads one from stdin/-i. + if (operation !== "delete") { + const raw = options.input + ? await fs.readFile(options.input, "utf-8") + : await readStdin(); + if (options.scope === "parent") { + // A move's payload is its JSON destination: {"parent": [...], "place": ...} + instruction.destination = JSON.parse(raw); + } else if (targetType === "frontmatter" && options.scope !== "marker") { + // Frontmatter values are structured JSON; fall back to the raw + // string so `echo done | mdpatch patch replace frontmatter status` + // does what it looks like. + try { + instruction.value = JSON.parse(raw); + } catch { + instruction.value = raw.replace(/\n$/, ""); + } + } else if (options.scope === "marker") { + // A marker payload is a single-line label (heading text, block id, + // frontmatter key), so a shell pipeline's trailing newline is + // framing, not content — without this, `echo New Name | mdpatch + // patch replace heading Old -s marker` is rejected for the line + // break every pipeline appends. Body content keeps its bytes. + instruction.content = raw.replace(/\r?\n$/, ""); + } else { + instruction.content = raw; + } + } - const instruction = { - operation, - targetType, - content, - target: - targetType !== "heading" ? target : target.split(options.delimiter), - } as PatchInstruction; - - const patchedDocument = applyPatch(document, instruction); - if (options.output === "-") { - process.stdout.write(patchedDocument); - } else { - await fs.writeFile( - options.output ? options.output : documentPath, - patchedDocument - ); + const document = await fs.readFile(documentPath, "utf-8"); + let result: PatchResult; + try { + result = patch(document, instruction as InstructionInput); + } catch (e) { + fail(e); } + printWarnings(result); + await writeResult(result.document, options.output, documentPath); } ); program .command("apply") + .description( + "Apply a JSON patch file: a single instruction object or an array of " + + "them, applied in order." + ) .argument("", "file to patch") - .argument("", "patch file to apply") + .argument("", "patch file to apply; use '-' for stdin") .option( "-o, --output ", "write output to the specified path instead of applying in-place; use '-' for stdout" ) - .action(async (path: string, patch: string, options) => { - let patchParsed: PatchInstruction[]; + .action(async (path: string, patchPath: string, options) => { let patchData: string; try { - if (patch === "-") { - patchData = await readStdin(); - } else { - patchData = await fs.readFile(patch, "utf-8"); - } + patchData = + patchPath === "-" + ? await readStdin() + : await fs.readFile(patchPath, "utf-8"); } catch (e) { console.error("Failed to read patch: ", e); process.exit(1); } + let instructions: InstructionInput[]; try { - const parsedData = JSON.parse(patchData); - if (!Array.isArray(parsedData)) { - patchParsed = [parsedData]; - } else { - patchParsed = parsedData; - } + const parsed: unknown = JSON.parse(patchData); + // Each instruction is schema-validated by `patch` at the boundary; the + // only shape enforced here is object-or-array-of-objects. + instructions = (Array.isArray(parsed) + ? parsed + : [parsed]) as InstructionInput[]; } catch (e) { console.error("Could not parse patch file as JSON"); process.exit(1); } let document = await fs.readFile(path, "utf-8"); - console.log("Document", document); - for (const instruction of patchParsed) { - document = applyPatch(document, instruction); + try { + for (const instruction of instructions) { + const result = patch(document, instruction); + printWarnings(result); + document = result.document; + } + } catch (e) { + fail(e); } - if (options.output === "-") { - process.stdout.write(document); - } else { - await fs.writeFile(options.output ? options.output : path, document); - } + await writeResult(document, options.output, path); }); program .command("query") + .description( + "Read a target's content: markdown for headings and blocks, JSON for " + + "frontmatter values." + ) .option( "-o, --output ", - "Path to write output to; use '-' for stdout. Defaults to patching in-place." + "Path to write output to; defaults to stdout." ) .option( "-d, --delimiter ", "Heading delimiter to use in place of '::'.", "::" ) - .argument("", "Target type ('heading', 'block', etc.)") + .option( + "-s, --scope ", + "Scope to read ('content', 'marker', 'markerAndContent'); defaults to " + + "'content'. Reads mirror writes: what a scope returns is what a " + + "'replace' at that scope consumes." + ) + .argument( + "", + "Target type ('heading', 'block', 'frontmatter')" + ) .argument( "", - "Target ('::'-delimited by default for Headings); see `mdpatch print-map ` for options)" + "Target ('::'-delimited containment path for headings, '' for the " + + "document root; a bare block id; a frontmatter key); see `mdpatch " + + "print-map ` for options" ) .argument("", "Path to document to query from.") .action( - async ( - targetType: PatchTargetType, - target: string, - documentPath: string, - options - ) => { + async (targetType: string, target: string, documentPath: string, options) => { const document = await fs.readFile(documentPath, "utf-8"); - const documentMap = getDocumentMap(document); - const actualTarget = - targetType !== "heading" - ? target - : target.split(options.delimiter).join("\u001f"); - - const value = document.slice( - documentMap[targetType][actualTarget].content.start, - documentMap[targetType][actualTarget].content.end - ); + let result; + try { + result = readTarget(document, { + targetType, + target: parseTarget(targetType as TargetType, target, options.delimiter), + ...(options.scope !== undefined ? { scope: options.scope } : {}), + } as Parameters[1]); + } catch (e) { + fail(e); + } + + const value = + result.kind === "frontmatter" + ? JSON.stringify(result.value, null, 2) + : result.content; if (options.output) { await fs.writeFile(options.output, value); diff --git a/src/constants.ts b/src/constants.ts index 7ae8952..ed5270e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,3 +11,39 @@ export const TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE = [ export const CAN_INCLUDE_BLOCK_REFERENCE = ["paragraph", "list_item", "table"]; export const DEFAULT_TARGET_SCOPE = "content"; + +/** + * Reserved codepoints used to disambiguate a duplicate sibling heading's + * address — never written into a document, only synthesized into an + * ephemeral map key or target address. Both live in Supplementary Private + * Use Area-A (U+F0000-U+FFFFD), well clear of the plane's start (Material + * Design Icons claims U+F0001+) and of known BMP PUA clusters (legacy + * Microsoft symbol fonts, Font Awesome, Octicons, the Apple logo glyph), + * and far enough apart from each other that no plausible contiguous foreign + * assignment could span both. + * + * DUPLICATE_DIGITS is 16 sequential codepoints — an arbitrary reserved + * alphabet, not real Unicode digits — so an occurrence index is encoded in + * hex, one reserved codepoint per hex digit, via {@link encodeOccurrenceSuffix} + * in projection.ts. + */ +export const DUPLICATE_MARKER = String.fromCodePoint(0xfc750); + +export const DUPLICATE_DIGITS: readonly string[] = Array.from( + { length: 16 }, + (_, index) => String.fromCodePoint(0xf6440 + index) +); + +/** + * Matches a string ending in {@link DUPLICATE_MARKER} immediately followed by + * one or more {@link DUPLICATE_DIGITS} codepoints, with nothing after — the + * exact shape a synthesized disambiguation suffix takes. Used to guard + * against a raw heading or block id in the source document colliding with a + * synthesized address. The `u` flag is required: these are astral + * codepoints, and without it the character class would match surrogate code + * units rather than whole codepoints. + */ +export const DUPLICATE_MARKER_SUFFIX = new RegExp( + `${DUPLICATE_MARKER}[${DUPLICATE_DIGITS.join("")}]+$`, + "u" +); diff --git a/src/debug.ts b/src/debug.ts deleted file mode 100644 index d8d8855..0000000 --- a/src/debug.ts +++ /dev/null @@ -1,89 +0,0 @@ -import chalk from "chalk"; -import { DocumentMap } from "./types.js"; - -export const printMap = ( - content: string, - documentMap: DocumentMap, - regex: RegExp | undefined -): void => { - for (const frontmatterField in documentMap.frontmatter) { - const blockName = `[${chalk.magenta("frontmatter")}] ${chalk.blueBright(frontmatterField)}`; - console.log("\n" + blockName + "\n"); - console.log(JSON.stringify(documentMap.frontmatter[frontmatterField])); - } - - const targetablePositions = { - heading: documentMap.heading, - block: documentMap.block, - }; - for (const type in targetablePositions) { - for (const positionName in targetablePositions[ - type as keyof typeof targetablePositions - ]) { - const position = - targetablePositions[type as keyof typeof targetablePositions][ - positionName - ]; - - const blockName = `[${chalk.magenta(type)}] ${positionName - .split("\u001f") - .map((pos) => chalk.blueBright(pos)) - .join(",")}`; - if (regex && !blockName.match(regex)) { - continue; - } - console.log("\n" + blockName + "\n"); - if (position.content.start < position.marker.start) { - console.log( - content - .slice(position.content.start - 100, position.content.start) - .replaceAll("\n", "\\n\n") + - chalk.black.bgGreen( - content - .slice(position.content.start, position.content.end) - .replaceAll("\n", "\\n\n") - ) + - content - .slice( - position.content.end, - Math.min(position.content.end + 100, position.marker.start) - ) - .replaceAll("\n", "\\n\n") + - chalk.black.bgRed( - content - .slice(position.marker.start, position.marker.end) - .replaceAll("\n", "\\n\n") - ) + - content - .slice(position.marker.end, position.marker.end + 100) - .replaceAll("\n", "\\n\n") - ); - } else { - console.log( - content - .slice(position.marker.start - 100, position.marker.start) - .replaceAll("\n", "\\n\n") + - chalk.black.bgRed( - content - .slice(position.marker.start, position.marker.end) - .replaceAll("\n", "\\n\n") - ) + - content - .slice( - position.marker.end, - Math.min(position.marker.end + 100, position.content.start) - ) - .replaceAll("\n", "\\n\n") + - chalk.black.bgGreen( - content - .slice(position.content.start, position.content.end) - .replaceAll("\n", "\\n\n") - ) + - content - .slice(position.content.end, position.content.end + 100) - .replaceAll("\n", "\\n\n") - ); - } - } - } -}; diff --git a/src/engine.ts b/src/engine.ts new file mode 100644 index 0000000..c1e68a6 --- /dev/null +++ b/src/engine.ts @@ -0,0 +1,566 @@ +/** + * The 2.0 patch engine: `patch(document, instruction)` builds the model, + * validates the requested cell of the algebra, checks the `ifMatch` + * precondition, resolves the target node, and dispatches to the handler for the + * requested operation×scope. Every mutation is expressed as a set of + * non-overlapping byte-range edits and applied in one splice, so regions + * outside the edit are byte-preserved. + * + * This module covers the plain-string *write* cells (`replace`/`prepend`/ + * `append`) for heading and block targets. Structural cells (move, dissolve, + * delete), frontmatter cells, and target creation are layered on in sibling + * modules and later increments; until then those cells raise a clear error. + */ + +import { buildModel, DocumentModel, SectionNode, BlockNode, BodyChild } from "./model.js"; +import { resolveTarget } from "./resolve.js"; +import { Edit } from "./splice.js"; +import { + headingMarkerRange, + headingContentRange, + subtreeContentRange, + subtreeEnd, + blockFullRange, +} from "./ranges.js"; +import { toLineEnding, sectionFragment, ownedGaps, lineStartGap, splice } from "./text.js"; +import { rebaseHeadings } from "./levels.js"; +import { structuralHeading, deleteBlock, consumeTrailingBlank } from "./engine/structural.js"; +import { patchFrontmatter } from "./engine/frontmatter.js"; +import { createHeading, createBlock } from "./engine/create.js"; +import { patchTableRows } from "./engine/table.js"; +import { + Instruction, + InstructionInput, + HeadingInstruction, + HeadingWithinInstruction, + BlockInstruction, + PatchResult, + EngineError, + PreconditionFailedError, + TargetNotFoundError, + ContentPreexistsError, + InvalidInstructionError, + assertValidCell, + withDefaultScope, + isBlockTableRowInstruction, + isWithinInstruction, +} from "./instructions.js"; +import { InstructionInputSchema } from "./schema.js"; +import { ResolvedTarget } from "./resolve.js"; + +/** The subset of an instruction {@link assertValidCell} inspects. */ +const cellOf = (instruction: Instruction) => ({ + targetType: instruction.targetType, + operation: instruction.operation, + scope: instruction.scope, +}); + +/** The parent's source heading level, or 0 when the parent is the root. */ +const parentLevel = (section: SectionNode): number => + section.parent?.heading?.level ?? 0; + +/** + * The current text of the span a write targets, for the `rejectIfContentPreexists` + * idempotency guard. Returns `null` when the notion does not apply (frontmatter, + * or a heading marker on the markerless root). + */ +const scopeSpanText = ( + document: string, + resolved: ResolvedTarget, + scope: string +): string | null => { + if (resolved.kind === "heading") { + const section = resolved.section; + const range = + scope === "content" + ? headingContentRange(section) + : scope === "marker" + ? section.marker + : subtreeContentRange(section); + return range ? document.slice(range.start, range.end) : null; + } + if (resolved.kind === "block") { + const block = resolved.block; + const range = + scope === "content" + ? block.content + : scope === "marker" + ? block.marker + : blockFullRange(block); + return document.slice(range.start, range.end); + } + if (resolved.kind === "headingChild") { + // `content`: the addressed block itself. `markerAndContent` (sibling + // insert): the whole section body, so a retried insert is still refused + // even though the new block lands outside the addressed child. + const range = + scope === "content" + ? resolved.child.range + : headingContentRange(resolved.section); + return document.slice(range.start, range.end); + } + return null; +}; + +/** + * The text to search for within {@link scopeSpanText} when checking + * `rejectIfContentPreexists`. The span is always absolute-level document + * text, but a heading write's `content` carries levels *relative* to the + * edited span (see `levels.ts`) — so a naive substring check against the raw + * caller value never matches content containing `#` headings, silently + * defeating the idempotency guard. Rebasing the value the same way the write + * path would (before splicing it in) keeps the comparison apples-to-apples. + * `marker` scope and non-heading targets carry no heading semantics, so the + * value is compared as-is. + */ +const preexistsProbe = ( + content: string, + resolved: ResolvedTarget, + scope: string +): string => { + if (resolved.kind === "headingChild") { + // A content-scope within write is a literal splice (never rebased); a + // markerAndContent sibling insert splices a fragment rebased to the + // section's level, so compare what the write path would produce. + return scope === "markerAndContent" + ? rebaseHeadings(content, resolved.section.heading?.level ?? 0).text + : content; + } + if (resolved.kind !== "heading") { + return content; + } + if (scope === "content") { + return rebaseHeadings(content, resolved.section.heading?.level ?? 0).text; + } + if (scope === "markerAndContent") { + return rebaseHeadings(content, parentLevel(resolved.section)).text; + } + return content; +}; + +// --- Heading handlers ---------------------------------------------------- + +const patchHeading = ( + document: string, + model: DocumentModel, + // `within` instructions resolve to a headingChild and dispatch to + // patchHeadingChild instead; excluding them here keeps the write-narrowing + // below (to HeadingWriteInstruction) sound. + instruction: Exclude, + section: SectionNode +): PatchResult => { + if (instruction.operation === "delete" || instruction.scope === "parent") { + return structuralHeading(document, model, instruction, section); + } + // Excluding delete and parent narrows to HeadingWriteInstruction. + const { operation, scope, content: value } = instruction; + + if (scope === "content") { + const fragment = sectionFragment(value, section.heading?.level ?? 0, model.lineEnding); + const content = headingContentRange(section); + const empty = content.start === content.end; + // The blank-line run between the marker and the first body text is a + // library-owned separator: replace swaps the value and leaves it standing, + // prepend inserts below it. (An empty replacement is a body delete and + // clears the full span, separator included.) + const bodyStart = fragment.text + ? bodyStartPast(document, content) + : content.start; + switch (operation) { + case "replace": + return splice( + document, + [{ range: { start: bodyStart, end: content.end }, text: fragment.text }], + fragment.warnings + ); + case "prepend": + // The inserted block faces body text below: the library owes the + // blank line that keeps it a separate block. + return splice( + document, + [ + blockEdit(document, model, { start: bodyStart, end: bodyStart }, fragment.text, { + padBefore: false, + padAfter: !empty, + }), + ], + fragment.warnings + ); + case "append": + // Mirror image: the joint above faces the body's last line. Below is + // the owned trailing gap (or the next marker/EOF), never body text. + return splice( + document, + [ + blockEdit(document, model, { start: content.end, end: content.end }, fragment.text, { + padBefore: !empty, + padAfter: false, + }), + ], + fragment.warnings + ); + } + } + + if (scope === "marker") { + return splice(document, [markerRenameEdit(document, model, section, operation, value)], []); + } + + // markerAndContent: the whole subtree, rebased to the parent's level. The + // joint below a subtree edit is always a heading marker, a gap, or EOF — + // self-delimiting, so no separator is owed there. Above, a separator is + // owed only when the fragment itself does not open with a heading line + // (a heading interrupts a paragraph; plain text does not). + const fragment = sectionFragment(value, parentLevel(section), model.lineEnding); + const padBefore = !/^#{1,6} /.test(fragment.text); + if (operation === "replace") { + return splice( + document, + [ + blockEdit(document, model, subtreeContentRange(section), fragment.text, { + padBefore, + padAfter: false, + }), + ], + fragment.warnings + ); + } + if (!section.heading) { + throw new EngineError( + "cannot insert a sibling of the document root" + ); + } + // Sibling insert: before the subtree (prepend) or after it, past its gap + // (append), so the target keeps its separator from what already surrounds it. + const at = + operation === "prepend" + ? subtreeContentRange(section).start + : subtreeEnd(section); + return splice( + document, + [ + blockEdit(document, model, { start: at, end: at }, fragment.text, { + padBefore, + padAfter: false, + }), + ], + fragment.warnings + ); +}; + +/** The offset of the first body text in `content`, past any leading blank run. */ +const bodyStartPast = ( + document: string, + content: { start: number; end: number } +): number => { + const leading = /^(?:[^\S\r\n]*(?:\r\n|\r|\n))+/.exec( + document.slice(content.start, content.end) + ); + return content.start + (leading ? leading[0].length : 0); +}; + +/** + * Build the edit for a `content`-scope write on a block's literal text span. + * Block content is spliced exactly as given — the caller owns the joint — since + * this is the documented contract for inline edits within a `^id` block. + */ +const contentEdit = ( + content: { start: number; end: number }, + operation: "replace" | "prepend" | "append", + text: string +): Edit => { + switch (operation) { + case "replace": + return { range: content, text }; + case "prepend": + return { range: { start: content.start, end: content.start }, text }; + case "append": + return { range: { start: content.end, end: content.end }, text }; + } +}; + +/** + * Build the edit splicing a canonical fragment over `range`, adding the + * blank-line separators the library owes on the sides where the caller says a + * separator is due (`padBefore`/`padAfter`). On a due side, `ownedGaps` + * contributes only what is missing: an existing blank line or a document edge + * needs nothing. A side owing no blank line still owes a *line start* — a + * flush joint against a terminator-less last line gets the single ending that + * keeps the fragment off that line. An empty fragment clears the range + * without separators (empty replacement is deletion; an empty insert is a + * no-op). + */ +const blockEdit = ( + document: string, + model: DocumentModel, + range: { start: number; end: number }, + text: string, + pads: { padBefore: boolean; padAfter: boolean } +): Edit => { + if (!text) { + return { range, text }; + } + const gaps = ownedGaps(document, range, model.lineEnding); + const before = pads.padBefore + ? gaps.before + : lineStartGap(document, range.start, model.lineEnding); + return { + range, + text: before + text + (pads.padAfter ? gaps.after : ""), + }; +}; + +/** Rebuild a heading line with renamed/prefixed/suffixed label text. */ +const markerRenameEdit = ( + document: string, + model: DocumentModel, + section: SectionNode, + operation: "replace" | "prepend" | "append", + value: string +): Edit => { + const range = headingMarkerRange(section); // throws for the root + const markerText = document.slice(range.start, range.end); + const hasEol = /(?:\r\n|\r|\n)$/.test(markerText); + const eol = hasEol ? model.lineEnding : ""; + const oldText = section.heading?.text ?? ""; + const newText = + operation === "replace" + ? value + : operation === "prepend" + ? value + oldText + : oldText + value; + const level = section.heading?.level ?? 1; + return { range, text: "#".repeat(level) + " " + newText + eol }; +}; + +// --- Heading body-block (within) handlers -------------------------------- + +/** + * Apply a `within`-refined heading instruction to one top-level block of the + * section's direct body. `content`-scope writes are literal splices — the + * caller owns the joint, exactly the `^id` block contract, which is what lets + * an `append` *continue* an existing paragraph or list — and `content`-scope + * delete removes the block plus its separator. `markerAndContent` + * `prepend`/`append` insert a *new* block beside the addressed one, with the + * library-owned separators every new-block insertion gets. + */ +const patchHeadingChild = ( + document: string, + model: DocumentModel, + instruction: HeadingWithinInstruction, + section: SectionNode, + child: BodyChild, + index: number +): PatchResult => { + if (instruction.scope === "markerAndContent") { + // Sibling insert. Levels in the fragment are relative to the section, + // the same baseline as a content-scope write into the same body. + const fragment = sectionFragment( + instruction.content, + section.heading?.level ?? 0, + model.lineEnding + ); + // "append after child k" ≡ "insert before child k+1" (or at the body end + // for the last child): anchoring on the next sibling keeps the insert + // *past* any isolated `^id` marker line annotating the addressed child, + // so the marker stays bound to its block. + const siblings = section.bodyChildren; + const at = + instruction.operation === "prepend" + ? child.range.start + : index + 1 < siblings.length + ? siblings[index + 1].range.start + : section.body.end; + return splice( + document, + [ + blockEdit(document, model, { start: at, end: at }, fragment.text, { + // The heading line above the body start, and the owned trailing + // gap / next marker / EOF below the body end, are self-delimiting. + padBefore: at !== section.body.start, + padAfter: at !== section.body.end, + }), + ], + fragment.warnings + ); + } + if (instruction.operation === "delete") { + const end = consumeTrailingBlank(document, child.range.end); + return splice( + document, + [{ range: { start: child.range.start, end }, text: "" }], + [] + ); + } + // Literal splice on the block's own span; only line endings are normalized. + const value = toLineEnding(instruction.content, model.lineEnding); + return splice(document, [contentEdit(child.range, instruction.operation, value)], []); +}; + +// --- Block handlers ------------------------------------------------------ + +const patchBlock = ( + document: string, + model: DocumentModel, + instruction: BlockInstruction, + block: BlockNode +): PatchResult => { + if (instruction.operation === "delete") { + return deleteBlock(document, model, instruction, block); + } + if (isBlockTableRowInstruction(instruction)) { + return patchTableRows(document, model, instruction, block); + } + // Excluding delete and table rows narrows to BlockWrite | BlockMarkerReplace; + // both carry a string `content`. Block content and ids are literal, never + // rebased. + const { operation, scope } = instruction; + const value = toLineEnding(instruction.content, model.lineEnding); + + if (scope === "content") { + return splice(document, [contentEdit(block.content, operation, value)], []); + } + + if (scope === "marker") { + // replace only (guaranteed by the cell matrix): swap the id, keeping any + // surrounding whitespace and the `^` sigil. + const markerText = document.slice(block.marker.start, block.marker.end); + const text = markerText.replace(/\^[A-Za-z0-9_-]+/, "^" + instruction.content); + return splice(document, [{ range: block.marker, text }], []); + } + + // markerAndContent: the whole block. + const full = blockFullRange(block); + if (operation === "replace") { + return splice(document, [{ range: full, text: value }], []); + } + // Sibling block insert, separated by a blank line (markdown block boundary). + const separator = model.lineEnding + model.lineEnding; + if (operation === "prepend") { + return splice( + document, + [{ range: { start: full.start, end: full.start }, text: value + separator }], + [] + ); + } + return splice( + document, + [{ range: { start: full.end, end: full.end }, text: separator + value }], + [] + ); +}; + +// --- Entry point --------------------------------------------------------- + +/** + * Apply a single instruction to `document`, returning the new document and any + * warnings. Accepts an {@link InstructionInput}: an omitted `scope` defaults to + * `content` before anything inspects the instruction. Throws + * {@link PreconditionFailedError} on an `ifMatch` mismatch, + * {@link TargetNotFoundError} when the target does not resolve, and + * {@link InvalidCellError} for a combination outside the algebra. + */ +export const patch = ( + document: string, + input: InstructionInput +): PatchResult => { + // Validate the whole instruction at the boundary: field shapes, the target + // shape for its type, cell validity, and the carrier the cell expects. A + // failure here means the caller sent a malformed instruction, so surface it + // as one typed error rather than letting a handler misread an absent field. + const parsed = InstructionInputSchema.safeParse(input); + if (!parsed.success) { + throw new InvalidInstructionError( + parsed.error.issues + .map((issue) => + issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message + ) + .join("; ") + ); + } + + const instruction = withDefaultScope(input); + const model = buildModel(document); + assertValidCell(cellOf(instruction)); + + if (instruction.ifMatch !== undefined && instruction.ifMatch !== model.version) { + throw new PreconditionFailedError( + `ifMatch precondition failed: expected version ${instruction.ifMatch}, document is at ${model.version}` + ); + } + + const resolved = resolveTarget(model, instruction); + if (!resolved) { + if (instruction.createTargetIfMissing) { + switch (instruction.targetType) { + case "heading": + return createHeading(document, model, instruction); + case "block": + return createBlock(document, model, instruction); + case "frontmatter": + // patchFrontmatter creates the key itself when it is missing. + return patchFrontmatter(document, model, instruction); + } + } + throw new TargetNotFoundError( + `could not resolve ${instruction.targetType} target ${JSON.stringify( + instruction.target + )}` + ); + } + + // rejectIfContentPreexists keeps prepend/append idempotent: refuse to insert + // string content that already appears in the target's current span. As in + // 1.x this applies to heading and block writes only, not frontmatter. + if ( + instruction.rejectIfContentPreexists && + (instruction.operation === "prepend" || instruction.operation === "append") && + "content" in instruction && + typeof instruction.content === "string" && + instruction.content.trim().length > 0 + ) { + const span = scopeSpanText(document, resolved, instruction.scope); + const probe = preexistsProbe(instruction.content, resolved, instruction.scope); + if (span !== null && span.includes(probe.trim())) { + throw new ContentPreexistsError( + `the target already contains the content to ${instruction.operation}` + ); + } + } + + // `resolveTarget` dispatches on `targetType`, so the resolved kind always + // matches the instruction; narrow explicitly for the type system. A + // `within` instruction resolves to a headingChild and only ever reaches + // patchHeadingChild — patchHeading never sees one. + if (instruction.targetType === "heading" && resolved.kind === "headingChild") { + if (!isWithinInstruction(instruction)) { + throw new EngineError( + "resolved a headingChild for an instruction without `within`" + ); + } + return patchHeadingChild( + document, + model, + instruction, + resolved.section, + resolved.child, + resolved.index + ); + } + if ( + instruction.targetType === "heading" && + resolved.kind === "heading" && + !isWithinInstruction(instruction) + ) { + return patchHeading(document, model, instruction, resolved.section); + } + if (instruction.targetType === "block" && resolved.kind === "block") { + return patchBlock(document, model, instruction, resolved.block); + } + if (instruction.targetType === "frontmatter" && resolved.kind === "frontmatter") { + return patchFrontmatter(document, model, instruction); + } + throw new EngineError( + `resolved ${resolved.kind} does not match ${instruction.targetType} target` + ); +}; diff --git a/src/engine/create.ts b/src/engine/create.ts new file mode 100644 index 0000000..da72066 --- /dev/null +++ b/src/engine/create.ts @@ -0,0 +1,133 @@ +/** + * Target creation for `createTargetIfMissing`. Only value-writing operations + * create — you cannot rename, move, or delete something that does not exist. + * + * - Headings: create the missing trailing segments of the address as a nested + * chain under the deepest existing ancestor, with levels running from that + * ancestor's *actual* level + 1 (so a skipped level in the address never + * leaves a hole — the 1.x skipped-depth bug), then place the content in the + * deepest new section. A created level past h6 still writes but warns. + * - Blocks: mint `content ^id` as a new paragraph at the end of the document. + */ + +import { DocumentModel } from "../model.js"; +import { resolveHeading } from "../resolve.js"; +import { subtreeEnd } from "../ranges.js"; +import { sectionFragment, toLineEnding, lineStartGap, splice } from "../text.js"; +import { + HeadingInstruction, + BlockInstruction, + PatchResult, + Warning, + EngineError, + TargetNotFoundError, + isBlockTableRowInstruction, +} from "../instructions.js"; + +const MAX_HEADING_LEVEL = 6; + +/** Create a missing heading (and any missing ancestors) and place content in it. */ +export const createHeading = ( + document: string, + model: DocumentModel, + instruction: HeadingInstruction +): PatchResult => { + if (instruction.operation === "delete" || instruction.scope === "parent") { + throw new TargetNotFoundError( + "cannot create a heading for a delete or move instruction" + ); + } + if (instruction.scope !== "content") { + throw new EngineError( + "createTargetIfMissing for headings supports content-scope writes only" + ); + } + const path = instruction.target ?? []; + if (path.length === 0) { + throw new EngineError("the document root cannot be created"); + } + + // Find the deepest existing ancestor prefix; the remaining segments are new. + let ancestor = model.root; + let matched = 0; + for (let length = path.length - 1; length >= 1; length--) { + const resolved = resolveHeading(model, path.slice(0, length)); + if (resolved) { + ancestor = resolved.section; + matched = length; + break; + } + } + const toCreate = path.slice(matched); + + const warnings: Warning[] = []; + const parts: string[] = []; + let level = ancestor.heading?.level ?? 0; + for (const segment of toCreate) { + level += 1; + if (level > MAX_HEADING_LEVEL) { + warnings.push({ + code: "heading-depth-overflow", + message: `Created heading "${segment}" resolves to level ${level}, beyond Markdown's maximum of ${MAX_HEADING_LEVEL}; it will not be structurally addressable.`, + }); + } + parts.push("#".repeat(level) + " " + segment + model.lineEnding); + } + + // The content lands in the deepest new section, at its level baseline. + const body = sectionFragment(instruction.content, level, model.lineEnding); + if (body.text) { + parts.push(body.text); + } + warnings.push(...body.warnings); + + // The chain opens with a heading marker, which owes no blank line — but the + // insertion point (the end of a terminator-less last line) may not be a + // line start, and a heading cannot begin mid-line. + const at = subtreeEnd(ancestor); + return splice( + document, + [ + { + range: { start: at, end: at }, + text: lineStartGap(document, at, model.lineEnding) + parts.join(""), + }, + ], + warnings + ); +}; + +/** Mint a new `content ^id` block at the end of the document. */ +export const createBlock = ( + document: string, + model: DocumentModel, + instruction: BlockInstruction +): PatchResult => { + if (instruction.operation === "delete" || instruction.scope !== "content") { + throw new EngineError( + "createTargetIfMissing for blocks supports content-scope writes only" + ); + } + if (isBlockTableRowInstruction(instruction)) { + throw new EngineError( + "createTargetIfMissing for blocks does not support table-row writes; create the table first, then append/prepend/replace rows" + ); + } + const value = toLineEnding(instruction.content, model.lineEnding); + const blockText = `${value} ^${instruction.target}`; + const le = model.lineEnding; + const separator = + document.length === 0 + ? "" + : document.endsWith(le + le) + ? "" + : document.endsWith(le) + ? le + : le + le; + const at = document.length; + return splice( + document, + [{ range: { start: at, end: at }, text: separator + blockText + le }], + [] + ); +}; diff --git a/src/engine/frontmatter.ts b/src/engine/frontmatter.ts new file mode 100644 index 0000000..2f90c5b --- /dev/null +++ b/src/engine/frontmatter.ts @@ -0,0 +1,157 @@ +/** + * Frontmatter cells. Unlike heading and block edits, which splice byte ranges, + * frontmatter is edited by parsing the block into an ordered list of key/value + * pairs, applying the operation to that list, and re-serializing the whole + * block with `yaml.stringify`. This sidesteps the fiddliness of serializing a + * single value in place (block scalars, flow vs. block style, quoting) at the + * cost of reformatting the block; the model's entry ranges are still used to + * resolve and order entries. Frontmatter values are JSON, never relative + * markdown, so nothing here is rebased. + */ + +import * as yaml from "yaml"; + +import { DocumentModel } from "../model.js"; +import { spliceRange } from "../splice.js"; +import { + isAppendableFrontmatterType, + isDictionary, + isList, + isString, +} from "../typeGuards.js"; +import { + FrontmatterInstruction, + PatchResult, + MergeError, + TargetNotFoundError, + FrontmatterKeyCollisionError, +} from "../instructions.js"; + +type Pair = [string, unknown]; + +/** List concat / dict merge / string concat, mirroring the 1.x merge rule. */ +const mergeValues = (a: unknown, b: unknown): unknown => { + if (isList(a) && isList(b)) { + return [...a, ...b]; + } + if (isDictionary(a) && isDictionary(b)) { + return { ...a, ...b }; + } + if (isString(a) && isString(b)) { + return a + b; + } + throw new MergeError( + "frontmatter values are not mergeable (need two lists, two dicts, or two strings)" + ); +}; + +/** Re-serialize the ordered pairs as a `---` block in the document's ending. */ +const serializeBlock = (pairs: Pair[], lineEnding: "\n" | "\r\n"): string => { + if (pairs.length === 0) { + return ""; + } + const object: Record = {}; + for (const [key, value] of pairs) { + object[key] = value; + } + const raw = `---\n${yaml.stringify(object).trimEnd()}\n---\n`; + return lineEnding === "\n" ? raw : raw.replaceAll("\n", lineEnding); +}; + +/** Apply a frontmatter instruction by regenerating the block from its pairs. */ +export const patchFrontmatter = ( + document: string, + model: DocumentModel, + instruction: FrontmatterInstruction +): PatchResult => { + const pairs: Pair[] = model.frontmatter.entries.map((entry) => [ + entry.key, + entry.value, + ]); + const key = instruction.target; + let index = pairs.findIndex(([existing]) => existing === key); + if (index === -1) { + // Creation is only meaningful when setting/merging a value: a content write + // or a whole-entry replace. Renaming, deleting, or inserting relative to a + // key that does not exist has no sensible meaning. + const creatable = + instruction.createTargetIfMissing && + ((instruction.scope === "content" && instruction.operation !== "delete") || + (instruction.scope === "markerAndContent" && + instruction.operation === "replace")); + if (!creatable) { + throw new TargetNotFoundError(`frontmatter key "${key}" was not found`); + } + // Seed an empty value of the payload's kind so a merge has something to + // merge onto; a replace overwrites it regardless. `creatable` guarantees a + // value instruction here, so `value` is present. + const content = "value" in instruction ? instruction.value : ""; + const seed = isList(content) ? [] : isDictionary(content) ? {} : ""; + pairs.push([key, seed]); + index = pairs.length - 1; + } + + if (instruction.scope === "marker") { + // Rename the key, keeping its value and position (replace-only per matrix). + const newKey = instruction.content; + if (newKey !== key && pairs.some(([existing]) => existing === newKey)) { + throw new FrontmatterKeyCollisionError( + `cannot rename frontmatter key "${key}" to "${newKey}": a key with that name already exists` + ); + } + pairs[index] = [newKey, pairs[index][1]]; + } else if (instruction.operation === "delete") { + if (instruction.scope === "content") { + pairs[index] = [key, null]; // clear the value, keep the key + } else { + pairs.splice(index, 1); // remove the whole entry + } + } else { + // Value instruction: replace / prepend / append at content or markerAndContent. + const content = instruction.value; + if (instruction.scope === "content") { + if (instruction.operation === "replace") { + pairs[index] = [key, content]; + } else { + const current = pairs[index][1]; + if (!isAppendableFrontmatterType(content) || !isAppendableFrontmatterType(current)) { + throw new MergeError( + `frontmatter key "${key}" cannot be merged with the given value` + ); + } + pairs[index] = [ + key, + instruction.operation === "append" + ? mergeValues(current, content) + : mergeValues(content, current), + ]; + } + } else if (instruction.operation === "replace") { + pairs[index] = [key, content]; // re-emit the whole entry with a new value + } else { + // Insert new entries (a dictionary) before/after the anchor entry. + if (!isDictionary(content)) { + throw new MergeError( + "inserting frontmatter entries requires a dictionary of key/value pairs" + ); + } + const incomingKeys = Object.keys(content); + const collision = incomingKeys.find((k) => + pairs.some(([existing]) => existing === k) + ); + if (collision) { + throw new FrontmatterKeyCollisionError( + `cannot insert frontmatter key "${collision}": a key with that name already exists` + ); + } + const at = instruction.operation === "prepend" ? index : index + 1; + pairs.splice(at, 0, ...(Object.entries(content) as Pair[])); + } + } + + const block = model.frontmatter.block ?? { start: 0, end: 0 }; + return { + document: spliceRange(document, block, serializeBlock(pairs, model.lineEnding)), + warnings: [], + }; +}; diff --git a/src/engine/structural.ts b/src/engine/structural.ts new file mode 100644 index 0000000..1f61fde --- /dev/null +++ b/src/engine/structural.ts @@ -0,0 +1,259 @@ +/** + * The structural cells of the algebra — the ones that reshape the tree rather + * than just rewrite a span: + * + * - `replace @ parent` (move): relocate a section's subtree beneath a new + * parent, re-levelling every heading in it by the same delta so its internal + * nesting is preserved. + * - `delete @ marker` (dissolve): remove a heading line. If a same-level + * sibling already precedes the target, its orphaned body/children are simply + * absorbed by the preceding text; if it is the first at its level, its + * children are promoted to its parent and re-levelled up one. + * - `delete @ content` / `delete @ markerAndContent`: empty the section's body + * (its subtree below the heading line), or remove the whole subtree — heading + * line included — plus its trailing gap. + * - block deletes: empty a block's text, detach its `^id`, or remove the whole + * block plus the blank line that separated it. + */ + +import { DocumentModel, SectionNode, BlockNode } from "../model.js"; +import { resolveHeading } from "../resolve.js"; +import { Edit } from "../splice.js"; +import { + headingMarkerRange, + headingContentRange, + subtreeContentRange, + subtreeEnd, + blockFullRange, +} from "../ranges.js"; +import { relevelText, endWithSingleEol, lineStartGap, splice } from "../text.js"; +import { + HeadingInstruction, + HeadingMoveInstruction, + BlockDeleteInstruction, + Place, + PatchResult, + EngineError, + TargetNotFoundError, +} from "../instructions.js"; + +/** The subtree's first byte: a section's own marker, or its body for the root. */ +const subtreeStart = (section: SectionNode): number => + section.marker ? section.marker.start : section.body.start; + +// --- Move ---------------------------------------------------------------- + +/** Reject moving a section beneath itself or one of its own descendants. */ +const assertNoCycle = (section: SectionNode, newParent: SectionNode): void => { + let node: SectionNode | null = newParent; + while (node) { + if (node === section) { + throw new EngineError( + "cannot move a section beneath itself or one of its descendants" + ); + } + node = node.parent; + } +}; + +/** The byte offset at which a new child should be inserted under `newParent`. */ +const childInsertOffset = ( + model: DocumentModel, + newParent: SectionNode, + place: Place +): number => { + const children = newParent.children; + if (place === "first") { + return children.length ? subtreeStart(children[0]) : newParent.body.end; + } + if (place === "last") { + return children.length + ? subtreeEnd(children[children.length - 1]) + : newParent.body.end; + } + const addr = "before" in place ? place.before : place.after; + const sibling = resolveHeading(model, addr)?.section; + if (!sibling || sibling.parent !== newParent) { + throw new TargetNotFoundError( + `place anchor ${JSON.stringify(addr)} is not a child of the new parent` + ); + } + return "before" in place ? subtreeStart(sibling) : subtreeEnd(sibling); +}; + +const moveSection = ( + document: string, + model: DocumentModel, + instruction: HeadingMoveInstruction, + section: SectionNode +): PatchResult => { + if (!section.heading) { + throw new EngineError("the document root cannot be moved"); + } + const resolvedParent = resolveHeading(model, instruction.destination.parent); + if (!resolvedParent) { + throw new TargetNotFoundError( + `could not resolve new parent ${JSON.stringify(instruction.destination.parent)}` + ); + } + const newParent = resolvedParent.section; + assertNoCycle(section, newParent); + + // Re-level the whole subtree so the moved section sits at (new parent + 1), + // shifting every descendant by the same delta to preserve internal nesting. + const newParentLevel = newParent.heading?.level ?? 0; + const delta = newParentLevel + 1 - section.heading.level; + const source = subtreeContentRange(section); + const releveled = relevelText( + document.slice(source.start, source.end), + delta, + model.lineEnding + ); + const movedText = endWithSingleEol(releveled.text, model.lineEnding); + + const removalStart = subtreeStart(section); + const removalEnd = subtreeEnd(section); + const removal: Edit = { + range: { start: removalStart, end: removalEnd }, + text: "", + }; + const at = childInsertOffset(model, newParent, instruction.destination.place); + + // A destination adjacent to the section's own span means it is not actually + // relocating (a self-anchored place, or a cross-parent move that lands in + // the same textual spot, e.g. a last child re-parented to precede the + // section that follows it). Remove-and-reinsert would consume the trailing + // separator the section already owns; re-level in place instead, leaving + // every surrounding byte untouched — a level-neutral move is then a + // byte-identical no-op. + if (at === removalStart || at === removalEnd) { + return splice( + document, + [{ range: source, text: releveled.text }], + releveled.warnings + ); + } + // The moved subtree opens with its own heading marker, which owes no blank + // line — but it still must begin at a line start, which the destination + // (the end of a terminator-less last line) may not provide. + const insertion: Edit = { + range: { start: at, end: at }, + text: lineStartGap(document, at, model.lineEnding) + movedText, + }; + + return splice(document, [removal, insertion], releveled.warnings); +}; + +// --- Dissolve ------------------------------------------------------------ + +const dissolveHeading = ( + document: string, + model: DocumentModel, + section: SectionNode +): PatchResult => { + const markerRange = headingMarkerRange(section); // throws for the root + const parent = section.parent!; + const index = parent.children.indexOf(section); + const hasPrecedingSameLevel = parent.children + .slice(0, index) + .some((sibling) => sibling.heading!.level === section.heading!.level); + + const edits: Edit[] = [{ range: markerRange, text: "" }]; + let warnings: PatchResult["warnings"] = []; + + // First at its level: its children have nothing to nest under once the + // heading is gone, so promote them to the parent by re-levelling up one. + if (!hasPrecedingSameLevel && section.children.length > 0) { + const start = subtreeStart(section.children[0]); + const end = subtreeContentRange(section).end; + const releveled = relevelText(document.slice(start, end), -1, model.lineEnding); + edits.push({ range: { start, end }, text: releveled.text }); + warnings = releveled.warnings; + } + + return splice(document, edits, warnings); +}; + +// --- Heading delete dispatch --------------------------------------------- + +/** Handle the structural heading cells (`delete @ *`, `replace @ parent`). */ +export const structuralHeading = ( + document: string, + model: DocumentModel, + instruction: HeadingInstruction, + section: SectionNode +): PatchResult => { + if (instruction.scope === "parent") { + return moveSection(document, model, instruction, section); + } + // Only delete instructions reach here (writes are handled in engine.ts). + if (instruction.operation !== "delete") { + throw new EngineError( + `structuralHeading received a non-structural instruction (${instruction.operation} @ ${instruction.scope})` + ); + } + switch (instruction.scope) { + case "content": + return splice(document, [{ range: headingContentRange(section), text: "" }], []); + case "markerAndContent": + return splice( + document, + [ + { + range: { start: subtreeStart(section), end: subtreeEnd(section) }, + text: "", + }, + ], + [] + ); + case "marker": + return dissolveHeading(document, model, section); + } +}; + +// --- Block delete -------------------------------------------------------- + +/** + * Consume the block's line terminator and, if one follows, a single blank-line + * separator, so removing a block does not leave a dangling gap. Shared with + * the `within` body-block delete in engine.ts, which follows the same contract. + */ +export const consumeTrailingBlank = (document: string, from: number): number => { + let i = from; + const eatEol = (): boolean => { + if (document[i] === "\r" && document[i + 1] === "\n") { + i += 2; + return true; + } + if (document[i] === "\n" || document[i] === "\r") { + i += 1; + return true; + } + return false; + }; + eatEol(); // the terminator ending the block's line + eatEol(); // one following blank line, if present + return i; +}; + +/** Handle the block delete cells (`delete @ content|marker|markerAndContent`). */ +export const deleteBlock = ( + document: string, + _model: DocumentModel, + instruction: BlockDeleteInstruction, + block: BlockNode +): PatchResult => { + switch (instruction.scope) { + case "content": + return splice(document, [{ range: block.content, text: "" }], []); + case "marker": + // Detach the id, keeping the content (the marker span includes any + // leading whitespace before `^`). + return splice(document, [{ range: block.marker, text: "" }], []); + case "markerAndContent": { + const full = blockFullRange(block); + const end = consumeTrailingBlank(document, full.end); + return splice(document, [{ range: { start: full.start, end }, text: "" }], []); + } + } +}; diff --git a/src/engine/table.ts b/src/engine/table.ts new file mode 100644 index 0000000..fd48f99 --- /dev/null +++ b/src/engine/table.ts @@ -0,0 +1,111 @@ +/** + * Table-row writes: `replace`/`prepend`/`append` @ `content` on a `block` + * target carrying structured `value` rather than literal `content` text (see + * {@link isBlockTableRowInstruction}). Unlike a literal-text block write, which + * splices `block.content` verbatim, a row write parses the table's header and + * separator lines out of that span and edits only the body rows beneath them. + */ + +import { DocumentModel, BlockNode } from "../model.js"; +import { + BlockTableRowInstruction, + PatchResult, + EngineError, +} from "../instructions.js"; +import { splice } from "../text.js"; + +/** The instruction's `value` targets a block whose `kind` isn't `"table"`. */ +export class NotATableError extends EngineError {} + +/** A row's cell count doesn't match the table's column count. */ +export class TableColumnCountError extends EngineError {} + +/** A cell's text cannot be represented inside a table row. */ +export class InvalidCellContentError extends EngineError {} + +interface ParsedTable { + header: string; + separator: string; + rows: string[]; + /** Whether `block.content` included its own trailing line ending: true when + * the `^id` sits inline in the same token (content runs up to the marker), + * false when it's isolated on its own line (content is EOL-stripped). */ + trailingEol: boolean; +} + +const EOL_AT_END = /\r\n$|\r$|\n$/; + +const parseTable = (text: string): ParsedTable => { + const trailingEol = EOL_AT_END.test(text); + const lines = (trailingEol ? text.replace(EOL_AT_END, "") : text).split(/\r\n|\r|\n/); + return { header: lines[0] ?? "", separator: lines[1] ?? "", rows: lines.slice(2), trailingEol }; +}; + +/** + * Render one cell's text as table-row source. A caller supplies cell + * *content* — that is the point of the array form — so the delimiters of the + * surrounding syntax are ours to escape, not theirs to remember. + * + * `|` is escaped to `\|`, the only escape GFM defines inside a table row; + * left alone it would end the cell early and shift every column after it, + * which the column-count check cannot catch because it counts entries in the + * supplied array, not cells in the rendered row. A backslash is *not* + * escaped: cell content is still markdown (a caller may legitimately write + * `\*` or a link), and doubling backslashes would rewrite that markdown. The + * cost is that a cell wanting a literal backslash immediately before a pipe + * cannot express it; that is rarer than writing markdown in a cell. + * + * A line break has no escape at all — a table row is one line by definition — + * so it is rejected rather than silently written, split, or turned into a + * `
` the caller never asked for. + */ +const formatCell = (cell: string): string => { + if (/[\r\n]/.test(cell)) { + throw new InvalidCellContentError( + `cell ${JSON.stringify(cell)} contains a line break, which cannot appear inside a table row` + ); + } + return cell.replace(/\|/g, "\\|"); +}; + +const formatRow = (row: string[]): string => + "| " + row.map(formatCell).join(" | ") + " |"; + +export const patchTableRows = ( + document: string, + model: DocumentModel, + instruction: BlockTableRowInstruction, + block: BlockNode +): PatchResult => { + if (block.kind !== "table" || !block.columns) { + throw new NotATableError( + `block "${block.id}" is not a table; row writes require a table block` + ); + } + + const columnCount = block.columns.length; + for (const row of instruction.value) { + if (row.length !== columnCount) { + throw new TableColumnCountError( + `row ${JSON.stringify(row)} has ${row.length} cell(s); table "${block.id}" has ${columnCount} column(s)` + ); + } + } + + const { header, separator, rows: existingRows, trailingEol } = parseTable( + document.slice(block.content.start, block.content.end) + ); + const newRows = instruction.value.map(formatRow); + + const bodyRows = + instruction.operation === "replace" + ? newRows + : instruction.operation === "prepend" + ? [...newRows, ...existingRows] + : [...existingRows, ...newRows]; + + const text = + [header, separator, ...bodyRows].join(model.lineEnding) + + (trailingEol ? model.lineEnding : ""); + return splice(document, [{ range: block.content, text }], []); +}; diff --git a/src/index.ts b/src/index.ts index 9b4e071..efe2512 100755 --- a/src/index.ts +++ b/src/index.ts @@ -2,16 +2,74 @@ * @module Reference */ +export type { DocumentRange } from "./types.js"; + +export { patch } from "./engine.js"; +export { buildModel } from "./model.js"; +export type { + DocumentModel, + SectionNode, + BlockNode, + BodyChild, + FrontmatterEntry, +} from "./model.js"; +export { projectMap, headingTreePaths } from "./projection.js"; +export type { PublicMap, HeadingTree } from "./projection.js"; +export { readTarget } from "./read.js"; +export type { ReadTarget, ReadResult, ReadScope } from "./read.js"; export { - PatchFailureReason, - PatchFailed, - PatchError, - TablePartsNotFound, - applyPatch, -} from "./patch.js"; -export { - getDocumentMap, + EngineError, + InvalidCellError, + InvalidInstructionError, + TargetNotFoundError, + PreconditionFailedError, + ContentPreexistsError, + MergeError, FrontmatterParseError, -} from "./map.js"; - -export * from "./types.js"; + FrontmatterKeyCollisionError, + ReservedDuplicateMarkerError, + isValidCell, + assertValidCell, + isBlockTableRowInstruction, + isWithinInstruction, +} from "./instructions.js"; +export { + InstructionInputSchema, + InstructionInputObjectSchema, +} from "./schema.js"; +export { + NotATableError, + TableColumnCountError, + InvalidCellContentError, +} from "./engine/table.js"; +export type { + Instruction, + InstructionInput, + HeadingInstruction, + HeadingWriteInstruction, + HeadingMoveInstruction, + HeadingDeleteInstruction, + HeadingWithinInstruction, + HeadingWithinWriteInstruction, + HeadingWithinDeleteInstruction, + HeadingWithinSiblingInsertInstruction, + BlockInstruction, + BlockWriteInstruction, + BlockMarkerReplaceInstruction, + BlockDeleteInstruction, + BlockTableRowInstruction, + FrontmatterInstruction, + FrontmatterValueInstruction, + FrontmatterRenameInstruction, + FrontmatterDeleteInstruction, + Operation, + Scope, + TargetType, + HeadingAddress, + ParentSpec, + Place, + PatchResult, + Warning, + WarningCode, + Cell, +} from "./instructions.js"; diff --git a/src/instructions.ts b/src/instructions.ts new file mode 100644 index 0000000..a48cf08 --- /dev/null +++ b/src/instructions.ts @@ -0,0 +1,391 @@ +/** + * Instruction shapes for the 2.0 patch engine. + * + * The engine is one algebra: an {@link Operation} applied to a {@link Scope} of + * a target node. The scope names what is edited (`content` = the body, `marker` + * = the label, `markerAndContent` = the whole node/subtree, `parent` = the + * node's place in the tree); the operation says what happens to it. + * + * An instruction carries its payload in exactly one of three mutually-exclusive + * fields, chosen by what the payload *is*, not by scope: + * - `content: string` — literal text: heading body/label, block text/id, and a + * frontmatter key rename. Heading-bearing content carries `#`-levels relative + * to the edited span's container (see `levels.ts`). + * - `value: unknown` — arbitrary structured JSON: frontmatter values. + * - `destination: ParentSpec` — where a moved section lands. + * + * These are plain TypeScript discriminated unions. A published Zod schema + * (from which obsidian-local-rest-api will derive its MCP and OpenAPI docs) is + * a deliberate follow-on; nothing here uses `any`. + */ + +export type Operation = "replace" | "prepend" | "append" | "delete"; +export type Scope = "content" | "marker" | "markerAndContent" | "parent"; +export type TargetType = "heading" | "block" | "frontmatter"; + +/** + * A heading address: the containment path of ancestor heading texts from the + * top level down (e.g. `["Overview", "Details"]`), or `null`/`[]` for the + * document root. The address names sections by nesting, never by heading + * depth: a level skipped in the source (an `h1` followed directly by an `h3`) + * simply does not appear, and `""` is a genuinely empty-text heading. The + * engine owns heading depth end to end, so a caller never encodes or counts + * levels. + */ +export type HeadingAddress = string[] | null; + +/** Where a moved section lands relative to its new parent's children. */ +export type Place = + | "first" + | "last" + | { before: HeadingAddress } + | { after: HeadingAddress }; + +/** The `destination` of a `replace @ parent` (move) instruction. */ +export interface ParentSpec { + /** The section's new parent, or `null`/`[]` to move to the document root. */ + parent: HeadingAddress; + /** The position among the new parent's children. */ + place: Place; +} + +interface BaseInstruction { + /** + * Optimistic-concurrency precondition: the `version` token from the map the + * caller planned against. When present and it does not equal the current + * document's version, the patch fails without modifying the document. + */ + ifMatch?: string; + /** Create the target (and any missing ancestors) when it does not exist. */ + createTargetIfMissing?: boolean; + /** Fail instead of applying when the value already appears in the target. */ + rejectIfContentPreexists?: boolean; +} + +interface HeadingTargeted extends BaseInstruction { + targetType: "heading"; + target: HeadingAddress; +} +interface BlockTargeted extends BaseInstruction { + targetType: "block"; + /** The bare block id, without the leading `^`. */ + target: string; +} +interface FrontmatterTargeted extends BaseInstruction { + targetType: "frontmatter"; + /** The top-level frontmatter key. */ + target: string; +} + +// --- Heading instructions ------------------------------------------------ + +/** + * `replace`/`prepend`/`append` on a heading's body, label, or whole subtree. + * String values carry heading levels *relative* to the edited span's container + * (see `levels.ts`), so no `#` counting is required. + */ +export interface HeadingWriteInstruction extends HeadingTargeted { + operation: "replace" | "prepend" | "append"; + scope: "content" | "marker" | "markerAndContent"; + content: string; +} +/** `replace @ parent`: move (and re-level) the section beneath a new parent. */ +export interface HeadingMoveInstruction extends HeadingTargeted { + operation: "replace"; + scope: "parent"; + /** The section's new placement in the tree. Carried in its own field so it is + * never confused with the string `content`/JSON `value` write carriers. */ + destination: ParentSpec; +} +/** + * `delete` a heading's body (`content`), its subtree (`markerAndContent`), or + * the heading line only (`marker`) — the last dissolving the section into what + * textually precedes it. + */ +export interface HeadingDeleteInstruction extends HeadingTargeted { + operation: "delete"; + scope: "content" | "marker" | "markerAndContent"; +} +/** + * `replace`/`prepend`/`append` on one positionally-addressed top-level block of + * a heading's direct body (`within`: 0-based document order, negative counting + * from the end). Unlike a plain heading content write, the text is spliced + * *literally* — the caller owns the joint, exactly as with a `^id` block + * content edit — so `append` can continue an existing paragraph or list. + */ +export interface HeadingWithinWriteInstruction extends HeadingTargeted { + operation: "replace" | "prepend" | "append"; + scope: "content"; + /** Index into the section's direct-body top-level blocks; negative from the end. */ + within: number; + content: string; +} +/** `delete` one positionally-addressed body block (and its separator). */ +export interface HeadingWithinDeleteInstruction extends HeadingTargeted { + operation: "delete"; + scope: "content"; + within: number; +} +/** + * `prepend`/`append @ markerAndContent` beside a positionally-addressed body + * block: insert `content` as a *new sibling block* immediately before/after it, + * with library-owned blank-line separators (the whitespace contract for new + * blocks — only the `content`-scope cells above splice literally). + */ +export interface HeadingWithinSiblingInsertInstruction extends HeadingTargeted { + operation: "prepend" | "append"; + scope: "markerAndContent"; + within: number; + content: string; +} +export type HeadingWithinInstruction = + | HeadingWithinWriteInstruction + | HeadingWithinDeleteInstruction + | HeadingWithinSiblingInsertInstruction; +export type HeadingInstruction = + | HeadingWriteInstruction + | HeadingMoveInstruction + | HeadingDeleteInstruction + | HeadingWithinInstruction; + +/** True when a heading instruction addresses a positional body block. */ +export const isWithinInstruction = ( + instruction: HeadingInstruction +): instruction is HeadingWithinInstruction => + "within" in instruction && instruction.within !== undefined; + +// --- Block instructions -------------------------------------------------- + +/** `replace`/`prepend`/`append` on a block's text or the whole block. */ +export interface BlockWriteInstruction extends BlockTargeted { + operation: "replace" | "prepend" | "append"; + scope: "content" | "markerAndContent"; + content: string; +} +/** `replace @ marker`: change the block's `^id`. */ +export interface BlockMarkerReplaceInstruction extends BlockTargeted { + operation: "replace"; + scope: "marker"; + /** The new block id, without the leading `^`. */ + content: string; +} +/** + * `delete` a block's text (`content`), the whole block (`markerAndContent`), or + * just the `^id` (`marker`, detaching the id while keeping the content). + */ +export interface BlockDeleteInstruction extends BlockTargeted { + operation: "delete"; + scope: "content" | "marker" | "markerAndContent"; +} +/** + * `replace`/`prepend`/`append` on a table block's rows: `value` is a 2-D array + * of cell text, one entry per row. Shares the `block` + `content` cell with + * {@link BlockWriteInstruction} — the carrier chosen (`content` vs `value`) + * decides whether the write is literal text or structured table rows. + * `replace` swaps the body rows (keeping the header/separator); `prepend`/ + * `append` insert before/after the existing body rows. + */ +export interface BlockTableRowInstruction extends BlockTargeted { + operation: "replace" | "prepend" | "append"; + scope: "content"; + value: string[][]; +} +export type BlockInstruction = + | BlockWriteInstruction + | BlockMarkerReplaceInstruction + | BlockDeleteInstruction + | BlockTableRowInstruction; + +/** True when `instruction` is a table-row write: a `block` target's `content` + * cell carrying structured `value` rather than literal `content` text. */ +export const isBlockTableRowInstruction = ( + instruction: BlockInstruction +): instruction is BlockTableRowInstruction => + instruction.scope === "content" && + instruction.operation !== "delete" && + "value" in instruction; + +// --- Frontmatter instructions -------------------------------------------- + +/** + * `replace`/`prepend`/`append` a frontmatter value (`content` scope) or whole + * entry (`markerAndContent`). `prepend`/`append` merge (list concat, dict + * merge, string concat). The payload is arbitrary JSON, never markdown, so it + * rides in the structured `value` carrier rather than the string `content` one + * used by heading and block writes. + */ +export interface FrontmatterValueInstruction extends FrontmatterTargeted { + operation: "replace" | "prepend" | "append"; + scope: "content" | "markerAndContent"; + value: unknown; +} +/** `replace @ marker`: rename the frontmatter key. */ +export interface FrontmatterRenameInstruction extends FrontmatterTargeted { + operation: "replace"; + scope: "marker"; + /** The new key name. */ + content: string; +} +/** `delete` a frontmatter value (`content`) or the whole entry (`markerAndContent`). */ +export interface FrontmatterDeleteInstruction extends FrontmatterTargeted { + operation: "delete"; + scope: "content" | "markerAndContent"; +} +export type FrontmatterInstruction = + | FrontmatterValueInstruction + | FrontmatterRenameInstruction + | FrontmatterDeleteInstruction; + +export type Instruction = + | HeadingInstruction + | BlockInstruction + | FrontmatterInstruction; + +/** + * Make `scope` optional on exactly those members whose scope union admits + * `content` — the write and delete cells — while leaving it required on the + * marker-only and parent-only members, where it selects a specific behavior and + * has no sensible default. Distributes over the {@link Instruction} union. + */ +type WithOptionalContentScope = T extends { scope: infer S } + ? "content" extends S + ? Omit & { scope?: S } + : T + : T; + +/** + * A public instruction as a caller may write it: `scope` may be omitted wherever + * it would default to `content` (mirroring 1.x's optional `targetScope`). + * {@link patch} normalizes this to a full {@link Instruction} before anything + * inspects it, so every internal handler still sees an explicit scope. + */ +export type InstructionInput = WithOptionalContentScope; + +/** Default an omitted `scope` to `content`, yielding a full {@link Instruction}. */ +export const withDefaultScope = (input: InstructionInput): Instruction => { + const scope = (input as { scope?: Scope }).scope ?? "content"; + return { ...input, scope } as Instruction; +}; + +// --- Result and warnings ------------------------------------------------- + +export type WarningCode = "heading-depth-overflow"; + +export interface Warning { + code: WarningCode; + message: string; +} + +export interface PatchResult { + document: string; + warnings: Warning[]; +} + +// --- Cell validity ------------------------------------------------------- + +/** + * The valid cells of the algebra: for each target type and scope, the + * operations that mean something. Cells absent here (e.g. `prepend @ parent`, + * any `parent` cell on a block or frontmatter target) are dead and rejected. + */ +const VALID_CELLS: Record> = { + heading: { + content: ["replace", "prepend", "append", "delete"], + marker: ["replace", "prepend", "append", "delete"], + markerAndContent: ["replace", "prepend", "append", "delete"], + parent: ["replace"], + }, + block: { + content: ["replace", "prepend", "append", "delete"], + marker: ["replace", "delete"], + markerAndContent: ["replace", "prepend", "append", "delete"], + parent: [], + }, + frontmatter: { + content: ["replace", "prepend", "append", "delete"], + marker: ["replace"], + markerAndContent: ["replace", "prepend", "append", "delete"], + parent: [], + }, +}; + +/** True when `operation` is meaningful for `scope` on a `targetType` target. */ +export const isValidCell = ( + targetType: TargetType, + operation: Operation, + scope: Scope +): boolean => VALID_CELLS[targetType][scope].includes(operation); + +/** The tuple {@link assertValidCell} inspects — a structural subset of an Instruction. */ +export interface Cell { + targetType: TargetType; + operation: Operation; + scope: Scope; +} + +// --- Errors -------------------------------------------------------------- + +/** Base class for every failure the 2.0 engine raises. */ +export class EngineError extends Error { + constructor(message: string) { + super(message); + this.name = new.target.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * The instruction failed schema validation at the {@link patch} boundary — a + * malformed field, a mismatched target shape, or the wrong payload carrier for + * its cell. Distinct from {@link InvalidCellError}, which is specifically an + * `operation × scope` combination outside the algebra. + */ +export class InvalidInstructionError extends EngineError {} + +/** The requested operation×scope combination is not part of the algebra. */ +export class InvalidCellError extends EngineError { + constructor(public cell: Cell) { + super( + `Invalid instruction: ${cell.operation} @ ${cell.scope} is not a valid operation for a ${cell.targetType} target` + ); + } +} + +/** The instruction's target could not be resolved in the document. */ +export class TargetNotFoundError extends EngineError {} + +/** The `ifMatch` precondition did not match the current document version. */ +export class PreconditionFailedError extends EngineError {} + +/** `rejectIfContentPreexists` was set and the value already exists at the target. */ +export class ContentPreexistsError extends EngineError {} + +/** A frontmatter merge or type mismatch made the operation impossible. */ +export class MergeError extends EngineError {} + +/** The frontmatter block could not be parsed as YAML. */ +export class FrontmatterParseError extends EngineError {} + +/** + * A frontmatter `marker`-scope rename, or a `markerAndContent` insert, would + * introduce a second entry with the same key — silently dropping one of the + * two values on serialization. + */ +export class FrontmatterKeyCollisionError extends EngineError {} + +/** + * A heading or block id, as written in the source document, already ends with + * the exact reserved sequence the engine uses to disambiguate a duplicate + * sibling heading's address (see {@link disambiguatedHeadingText} in + * `projection.ts`). Left unguarded, this could make a synthesized disambiguated + * address collide with real document content and silently resolve to the + * wrong section. + */ +export class ReservedDuplicateMarkerError extends EngineError {} + +/** Throw {@link InvalidCellError} unless the cell is part of the algebra. */ +export const assertValidCell = (cell: Cell): void => { + if (!isValidCell(cell.targetType, cell.operation, cell.scope)) { + throw new InvalidCellError(cell); + } +}; diff --git a/src/levels.ts b/src/levels.ts new file mode 100644 index 0000000..050cd80 --- /dev/null +++ b/src/levels.ts @@ -0,0 +1,75 @@ +/** + * Relative heading levels. A section value carries heading `#`-counts relative + * to the container of the span being written; on write they are rebased to + * absolute by adding a baseline: + * + * - `content` scope → baseline = the target section's own level (`#` = a direct + * child). + * - `markerAndContent` / sibling insert → baseline = the *parent's* level + * (`# Title` = a section at the target's own level). + * - document root → baseline 0, so relative == absolute and whole-document + * writes are byte-identical. + * + * This is well-founded because a section can never contain a heading at or above + * its own level, so stripping the container's level on read and re-adding it on + * write round-trips losslessly. A rebased level past h6 is still written (the + * `#`s go in verbatim) but is reported as a warning, since the "heading" will + * not be structurally addressable in the next map. + * + * The value is normalized to LF here; the engine re-applies the document's line + * ending when it splices the result in. + */ + +import * as marked from "marked"; + +import { applyEdits, Edit } from "./splice.js"; +import { Warning } from "./instructions.js"; + +const MAX_HEADING_LEVEL = 6; +const LEADING_HASHES = /^([ \t]{0,3})(#{1,})/; + +export interface RebaseResult { + text: string; + warnings: Warning[]; +} + +/** + * Rewrite each top-level heading's `#`-count in `value` by adding `baseline`, + * leaving `#` lines inside fenced code (and all other content) untouched. + */ +export const rebaseHeadings = (value: string, baseline: number): RebaseResult => { + const normalized = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (baseline === 0) { + // Nothing to shift; relative already equals absolute. + return { text: normalized, warnings: [] }; + } + + const tokens = new marked.Lexer().lex(normalized); + const edits: Edit[] = []; + const warnings: Warning[] = []; + let offset = 0; + for (const token of tokens) { + if (token.type === "heading") { + const match = LEADING_HASHES.exec(token.raw); + if (match) { + const hashStart = offset + match[1].length; + const hashEnd = hashStart + match[2].length; + const absoluteLevel = match[2].length + baseline; + edits.push({ + range: { start: hashStart, end: hashEnd }, + text: "#".repeat(absoluteLevel), + }); + if (absoluteLevel > MAX_HEADING_LEVEL) { + const heading = token as marked.Tokens.Heading; + warnings.push({ + code: "heading-depth-overflow", + message: `Heading "${heading.text.trim()}" resolves to level ${absoluteLevel}, beyond Markdown's maximum of ${MAX_HEADING_LEVEL}; it will not be structurally addressable.`, + }); + } + } + } + offset += token.raw.length; + } + + return { text: applyEdits(normalized, edits), warnings }; +}; diff --git a/src/map.ts b/src/map.ts deleted file mode 100644 index ab87f07..0000000 --- a/src/map.ts +++ /dev/null @@ -1,269 +0,0 @@ -import * as marked from "marked"; -import { parse as parseYaml } from "yaml"; - -import { - DocumentMap, - DocumentMapMarkerContentPair, - HeadingMarkerContentPair, - PreprocessedDocument, -} from "./types.js"; - -import { - CAN_INCLUDE_BLOCK_REFERENCE, - TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE, -} from "./constants.js"; - -export class FrontmatterParseError extends Error { - constructor(message: string) { - super(message); - this.name = "FrontmatterParseError"; - Object.setPrototypeOf(this, new.target.prototype); - } -} - -function getHeadingPositions( - document: string, - tokens: marked.TokensList, - contentOffset: number -): Record { - const positions: Record = { - "": { - content: { - start: contentOffset, - end: document.length + contentOffset, - }, - marker: { - start: 0, - end: 0, - }, - level: 0, - }, - }; - const stack: Array<{ heading: string; position: HeadingMarkerContentPair }> = - []; - - // Pre-compute the byte offset of every top-level token by accumulating raw - // lengths. marked's block tokens concatenate back to the original input, so - // this gives exact positions without any indexOf search — avoiding false - // matches inside code spans, table cells, fenced code blocks, etc. - const tokenOffsets: number[] = []; - let runningOffset = 0; - for (const token of tokens) { - tokenOffsets.push(runningOffset); - runningOffset += token.raw.length; - } - - tokens.forEach((token, index) => { - if (token.type === "heading") { - const headingToken = token as marked.Tokens.Heading; - - const startHeading = tokenOffsets[index]; - const endHeading = startHeading + headingToken.raw.trimEnd().length + 1; - const headingLevel = headingToken.depth; - - // Determine the start of the content after this heading - const startContent = endHeading; - - // Determine the end of the content before the next heading of the same or higher level, or end of document - let endContent: number | undefined = undefined; - for (let i = index + 1; i < tokens.length; i++) { - if ( - tokens[i].type === "heading" && - (tokens[i] as marked.Tokens.Heading).depth <= headingLevel - ) { - endContent = tokenOffsets[i]; - break; - } - } - if (endContent === undefined) { - endContent = document.length; - } - - const currentHeading: HeadingMarkerContentPair = { - content: { - start: startContent + contentOffset, - end: endContent + contentOffset, - }, - marker: { - start: startHeading + contentOffset, - end: endHeading + contentOffset, - }, - level: headingLevel, - }; - - // Build the full heading path with parent headings separated by \u001f - let fullHeadingPath = headingToken.text.trim(); - while ( - stack.length && - stack[stack.length - 1].position.level >= headingLevel - ) { - stack.pop(); - } - - if (stack.length) { - const parent = stack[stack.length - 1]; - parent.position.content.end = endContent + contentOffset; - fullHeadingPath = `${parent.heading}\u001f${fullHeadingPath}`; - } - - positions[fullHeadingPath] = currentHeading; - stack.push({ heading: fullHeadingPath, position: currentHeading }); - } - }); - - return positions; -} - -function getBlockPositions( - document: string, - tokens: marked.TokensList, - contentOffset: number -): Record { - const positions: Record = {}; - - let lastBlockDetails: - | { - token: marked.Token; - start: number; - end: number; - } - | undefined = undefined; - let startContent = 0; - let endContent = 0; - let endMarker = 0; - marked.walkTokens(tokens, (token) => { - const blockReferenceRegex = /[^\S\r\n]*\^([a-zA-Z0-9_-]+)\s*$/; - const found = document.indexOf(token.raw, startContent); - if (found === -1) { - // Token's raw text is not present in the document starting from the - // current position. This happens for inner tokens of blockquotes, - // whose raw omits the leading "> " prefix, so the text never appears - // verbatim in the document. Skip the token entirely to avoid - // corrupting the shared position state with -1-based arithmetic. - return; - } - startContent = found; - const match = blockReferenceRegex.exec(token.raw); - endContent = startContent + (match ? match.index : token.raw.length); - const startMarker = match ? startContent + match.index : -1; - endMarker = startContent + token.raw.length; - // The end of a list item token sometimes doesn't include the trailing - // newline -- i'm honestly not sure why, but treating it as - // included here would simplify my implementation - if ( - document.slice(endMarker - 1, endMarker) !== "\n" && - document.slice(endMarker, endMarker + 1) === "\n" - ) { - endMarker += 1; - } else if ( - document.slice(endMarker - 2, endMarker) !== "\r\n" && - document.slice(endMarker, endMarker + 2) === "\r\n" - ) { - endMarker += 2; - } - if (CAN_INCLUDE_BLOCK_REFERENCE.includes(token.type) && match) { - const name = match[1]; - if (!name || match.index === undefined) { - return; - } - - const finalStartContent = { - start: startContent, - end: endContent, - }; - if ( - finalStartContent.start === finalStartContent.end && - lastBlockDetails - ) { - finalStartContent.start = lastBlockDetails.start; - finalStartContent.end = lastBlockDetails.end; - } - - positions[name] = { - content: { - start: finalStartContent.start + contentOffset, - end: finalStartContent.end + contentOffset, - }, - marker: { - start: startMarker + contentOffset, - end: endMarker + contentOffset, - }, - }; - } - - if (TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE.includes(token.type)) { - // Apply the same trailing-newline adjustment as endMarker: if the raw - // doesn't include the trailing newline but the document has one right - // after, treat it as included so the -1 correctly strips it. - let adjustedEndContent = endContent; - if ( - document.slice(adjustedEndContent - 1, adjustedEndContent) !== "\n" && - document.slice(adjustedEndContent, adjustedEndContent + 1) === "\n" - ) { - adjustedEndContent += 1; - } else if ( - document.slice(adjustedEndContent - 2, adjustedEndContent) !== "\r\n" && - document.slice(adjustedEndContent, adjustedEndContent + 2) === "\r\n" - ) { - adjustedEndContent += 2; - } - lastBlockDetails = { - token: token, - start: startContent, - end: adjustedEndContent - 1, - }; - } - }); - - return positions; -} - -function preProcess(document: string): PreprocessedDocument { - const frontmatterRegex = - /^---(?:\r\n|\r|\n)(?:---(?:\r\n|\r|\n|$)|([\s\S]*?)(?:\r\n|\r|\n)---(?:\r\n|\r|\n|$))/; - - let content: string; - let contentOffset = 0; - let frontmatter: Record; - - const match = frontmatterRegex.exec(document); - if (match) { - const frontmatterText = (match[1] ?? "").trim(); // Captured frontmatter content - contentOffset = match[0].length; // Length of the entire frontmatter section including delimiters - - try { - frontmatter = parseYaml(frontmatterText) ?? {}; - } catch (e) { - throw new FrontmatterParseError( - `Could not parse document frontmatter: ${(e as Error).message}` - ); - } - content = document.slice(contentOffset); - } else { - content = document; - frontmatter = {}; - } - - return { - content, - contentOffset, - frontmatter, - }; -} - -export const getDocumentMap = (document: string): DocumentMap => { - const { frontmatter, contentOffset, content } = preProcess(document); - - const lexer = new marked.Lexer(); - const tokens = lexer.lex(content); - - const lineEnding = document.indexOf("\r\n") > -1 ? "\r\n" : "\n"; - - return { - heading: getHeadingPositions(content, tokens, contentOffset), - block: getBlockPositions(content, tokens, contentOffset), - frontmatter: frontmatter, - contentOffset: contentOffset, - lineEnding, - }; -}; diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..8759222 --- /dev/null +++ b/src/model.ts @@ -0,0 +1,625 @@ +import * as marked from "marked"; +import { isMap, isScalar, parseDocument } from "yaml"; +import { createHash } from "crypto"; + +import { DocumentRange } from "./types.js"; +import { + CAN_INCLUDE_BLOCK_REFERENCE, + DUPLICATE_MARKER_SUFFIX, + TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE, +} from "./constants.js"; +import { FrontmatterParseError, ReservedDuplicateMarkerError } from "./instructions.js"; + +/** + * A single section of a document: a heading plus the body that belongs + * directly to it (i.e. up to its first child heading), together with the + * blank-line separator the library owns. Child sections are stored as an + * ordered list; the tree *is* the nesting. Nothing contested is stored here + * (no path, no occurrence counter); those are derived when projecting or + * resolving a target. + */ +export interface SectionNode { + /** `null` for the synthetic document root. `level` is the *source* heading + * depth as written; canonical levels are derived, not stored. */ + heading: { text: string; level: number } | null; + /** The heading line (`# Foo\n`); `null` for the root. */ + marker: DocumentRange | null; + /** The section's direct body, excluding {@link trailingGap}. */ + body: DocumentRange; + /** The blank-line separator following {@link body} that the library owns. */ + trailingGap: DocumentRange; + /** Child sections, in document order. */ + children: SectionNode[]; + /** `^id`-bearing blocks that live directly in this section's body. */ + blocks: BlockNode[]; + /** + * The ordered top-level blocks of this section's *direct* body (paragraphs, + * lists, tables, code fences, …), the sequence a positional `within` index + * addresses. Like {@link blocks}, an overlay rather than a partition: + * blank-line gaps and isolated `^id` marker lines belong to no child, so + * the rendered block count here matches Obsidian's section cache. + */ + bodyChildren: BodyChild[]; + parent: SectionNode | null; +} + +/** One top-level block of a section's direct body. */ +export interface BodyChild { + /** marked token type: `paragraph`, `list`, `table`, `code`, `blockquote`, `hr`, … */ + kind: string; + /** + * The block's visible span, trailing line endings excluded (the same + * convention as {@link BlockNode.content} and Obsidian's block spans). An + * inline `^id` sits inside its block's token, so the span includes it. + */ + range: DocumentRange; +} + +/** + * A `^id`-bearing block. Blocks are an *overlay* onto the section tree: their + * ranges fall within their containing section's {@link SectionNode.body}, + * they do not tile the document themselves. + */ +export interface BlockNode { + id: string; + /** marked token type: `paragraph`, `table`, `list`, `blockquote`, … */ + kind: string; + /** Column header texts, for `table` blocks only. */ + columns?: string[]; + /** + * True when the `^id` sits alone on its own line and therefore targets the + * *preceding* block (Obsidian's isolated-block-reference rule): in that case + * {@link content} is the preceding block's span and {@link marker} is the + * detached `^id` line that follows it. False for an inline `^id` trailing a + * paragraph/table row, where content and marker are contiguous. + */ + isolated: boolean; + /** + * The region the block id addresses. For an inline block this is the token + * text preceding the `^id` marker; for an isolated block it is the whole + * preceding block. Never includes a trailing line ending (Obsidian excludes + * it from block spans). + */ + content: DocumentRange; + /** The `^id` token span, with any trailing line ending excluded. */ + marker: DocumentRange; + /** The blank-line separator following the block. */ + trailingGap: DocumentRange; + section: SectionNode; +} + +export interface FrontmatterEntry { + key: string; + value: unknown; + /** The full `key: value` span within the frontmatter block. */ + entryRange: DocumentRange; + /** The value span within {@link entryRange}. */ + valueRange: DocumentRange; +} + +export interface DocumentModel { + /** Short content hash of the source document; the future `ifMatch` token. */ + version: string; + lineEnding: "\n" | "\r\n"; + frontmatter: { + entries: FrontmatterEntry[]; + /** The `---` … `---` block span, or `null` when there is no frontmatter. */ + block: DocumentRange | null; + }; + root: SectionNode; +} + +interface PreprocessedDocument { + content: string; + contentOffset: number; + frontmatterText: string | null; +} + +const FRONTMATTER_REGEX = + /^---(?:\r\n|\r|\n)(?:---(?:\r\n|\r|\n|$)|([\s\S]*?)(?:\r\n|\r|\n)---(?:\r\n|\r|\n|$))/; + +const preProcess = (document: string): PreprocessedDocument => { + const match = FRONTMATTER_REGEX.exec(document); + if (!match) { + return { content: document, contentOffset: 0, frontmatterText: null }; + } + const contentOffset = match[0].length; + return { + content: document.slice(contentOffset), + contentOffset, + frontmatterText: match[1] ?? "", + }; +}; + +const versionOf = (document: string): string => + createHash("sha256").update(document, "utf8").digest("hex").slice(0, 6); + +/** + * marked collapses every `\r\n` (and lone `\r`) to `\n` in `token.raw`, so + * offsets accumulated from raw lengths drift against a CRLF source. We + * therefore tokenize a normalized copy and keep a map from each normalized + * offset back to the original offset, so stored ranges address the real bytes. + */ +const normalizeLineEndings = ( + input: string +): { normalized: string; toOriginal: number[] } => { + const toOriginal: number[] = []; + let normalized = ""; + let i = 0; + while (i < input.length) { + toOriginal.push(i); + if (input[i] === "\r" && input[i + 1] === "\n") { + normalized += "\n"; + i += 2; + } else if (input[i] === "\r") { + normalized += "\n"; + i += 1; + } else { + normalized += input[i]; + i += 1; + } + } + toOriginal.push(input.length); + return { normalized, toOriginal }; +}; + +/** Translate a normalized-content offset into an absolute original offset. */ +type Abs = (normalizedOffset: number) => number; + +/** + * Split a content-space range into its visible content and its trailing + * blank-line separator. The last visible line keeps exactly one line ending + * as part of the content; any further line endings are the trailing gap. A + * range that is entirely line endings is all gap (an empty section body). + */ +const splitTrailingGap = ( + content: string, + start: number, + end: number +): { contentEnd: number } => { + let visibleEnd = end; + while ( + visibleEnd > start && + (content[visibleEnd - 1] === "\n" || content[visibleEnd - 1] === "\r") + ) { + visibleEnd--; + } + if (visibleEnd === start) { + // Entire range is line endings: no visible content, all gap. + return { contentEnd: start }; + } + // Keep the terminating line ending of the final visible line with content. + let contentEnd = visibleEnd; + if (content[contentEnd] === "\r" && content[contentEnd + 1] === "\n") { + contentEnd += 2; + } else if (content[contentEnd] === "\n" || content[contentEnd] === "\r") { + contentEnd += 1; + } + return { contentEnd }; +}; + +interface HeadingSpan { + text: string; + level: number; + /** content-space offset of the heading line start. */ + markerStart: number; + /** content-space offset just past the heading line's terminator. */ + markerEnd: number; +} + +/** Locate every top-level heading token with exact content-space offsets. */ +const findHeadings = ( + content: string, + tokens: marked.TokensList +): HeadingSpan[] => { + const headings: HeadingSpan[] = []; + let offset = 0; + for (const token of tokens) { + if (token.type === "heading") { + const heading = token as marked.Tokens.Heading; + const markerStart = offset; + let markerEnd = markerStart + heading.raw.trimEnd().length; + if (content[markerEnd] === "\r" && content[markerEnd + 1] === "\n") { + markerEnd += 2; + } else if (content[markerEnd] === "\n" || content[markerEnd] === "\r") { + markerEnd += 1; + } + headings.push({ + text: heading.text.trim(), + level: heading.depth, + markerStart, + markerEnd, + }); + } + offset += token.raw.length; + } + return headings; +}; + +const buildSectionTree = ( + content: string, + abs: Abs, + headings: HeadingSpan[] +): SectionNode => { + const contentLength = content.length; + + const root: SectionNode = { + heading: null, + marker: null, + body: { start: 0, end: 0 }, + trailingGap: { start: 0, end: 0 }, + children: [], + blocks: [], + bodyChildren: [], + parent: null, + }; + + // Root's direct body runs from the top of the content region to the first + // heading (or the whole content region when there are no headings). + const rootBodyStart = 0; + const rootBodyEnd = headings.length ? headings[0].markerStart : contentLength; + const rootSplit = splitTrailingGap(content, rootBodyStart, rootBodyEnd); + root.body = { start: abs(rootBodyStart), end: abs(rootSplit.contentEnd) }; + root.trailingGap = { start: abs(rootSplit.contentEnd), end: abs(rootBodyEnd) }; + + const stack: SectionNode[] = [root]; + + headings.forEach((heading, index) => { + // A section's direct body ends at the next heading of any level (which is + // either its first child or the heading that closes it), or the end of the + // content region. + const bodyStart = heading.markerEnd; + const bodyEnd = + index + 1 < headings.length + ? headings[index + 1].markerStart + : contentLength; + const split = splitTrailingGap(content, bodyStart, bodyEnd); + + const node: SectionNode = { + heading: { text: heading.text, level: heading.level }, + marker: { start: abs(heading.markerStart), end: abs(heading.markerEnd) }, + body: { start: abs(bodyStart), end: abs(split.contentEnd) }, + trailingGap: { start: abs(split.contentEnd), end: abs(bodyEnd) }, + children: [], + blocks: [], + bodyChildren: [], + parent: null, + }; + + while ( + stack.length > 1 && + stack[stack.length - 1].heading!.level >= heading.level + ) { + stack.pop(); + } + const parent = stack[stack.length - 1]; + node.parent = parent; + parent.children.push(node); + stack.push(node); + }); + + return root; +}; + +const forEachSection = ( + root: SectionNode, + visit: (node: SectionNode) => void +): void => { + visit(root); + for (const child of root.children) { + forEachSection(child, visit); + } +}; + +/** Find the deepest section whose direct body contains the given offset. */ +const sectionContaining = (root: SectionNode, offset: number): SectionNode => { + let best = root; + forEachSection(root, (node) => { + if (offset >= node.body.start && offset < node.trailingGap.end) { + // Prefer the deepest (most specific) containing section. + if (node.body.start >= best.body.start) { + best = node; + } + } + }); + return best; +}; + +const BLOCK_REFERENCE_REGEX = /[^\S\r\n]*\^([a-zA-Z0-9_-]+)\s*$/; + +/** + * Back up over trailing line endings so a boundary sits just past the last + * visible character (`content` is normalized, so only `\n` occurs). Obsidian's + * block spans exclude every trailing newline — a token's raw may carry one (an + * inline paragraph) or a following blank line (a table) — so block boundaries + * are trimmed the same way. + */ +const stripTrailingEol = (content: string, end: number, start: number): number => { + let trimmed = end; + while (trimmed > start && content[trimmed - 1] === "\n") { + trimmed--; + } + return trimmed; +}; + +const findBlocks = ( + content: string, + abs: Abs, + tokens: marked.TokensList, + root: SectionNode +): BlockNode[] => { + const blocks: BlockNode[] = []; + // The most recent block-level token an isolated `^id` line can bind to, in + // content space with its trailing newline stripped. Mirrors the old engine's + // `lastBlockDetails` and matches Obsidian, which reports an isolated block's + // position as the preceding block rather than the marker line. + let lastIsolatedTarget: { start: number; end: number } | null = null; + + const visit = (token: marked.Token, found: number, rawEnd: number): void => { + const match = BLOCK_REFERENCE_REGEX.exec(token.raw); + if (match && CAN_INCLUDE_BLOCK_REFERENCE.includes(token.type)) { + const id = match[1]; + if (id) { + const markerStart = found + match.index; + const markerEnd = stripTrailingEol(content, rawEnd, found); + let contentStart = found; + let contentEnd = markerStart; + let isolated = false; + if (contentStart === contentEnd && lastIsolatedTarget) { + // Nothing precedes the `^id` on its line: it targets the block above. + contentStart = lastIsolatedTarget.start; + contentEnd = lastIsolatedTarget.end; + isolated = true; + } + const section = sectionContaining(root, abs(contentStart)); + const block: BlockNode = { + id, + kind: token.type, + isolated, + content: { start: abs(contentStart), end: abs(contentEnd) }, + marker: { start: abs(markerStart), end: abs(markerEnd) }, + trailingGap: { start: abs(markerEnd), end: abs(markerEnd) }, + section, + }; + if (token.type === "table") { + block.columns = (token as marked.Tokens.Table).header.map( + (cell) => cell.text + ); + } + blocks.push(block); + section.blocks.push(block); + } + } + + if (TARGETABLE_BY_ISOLATED_BLOCK_REFERENCE.includes(token.type)) { + lastIsolatedTarget = { + start: found, + end: stripTrailingEol(content, rawEnd, found), + }; + } + }; + + /** + * Walk a sibling token array in the same pre-order `marked.walkTokens` + * uses (a token, then its own children, before its next sibling), + * mirroring its `table`/`list`/default child dispatch. A sibling never + * anchors earlier than `floor`, and — critically — the floor handed to the + * *next* sibling only advances past everything the current token (and its + * descendants) consumed, so two byte-identical sibling tokens (e.g. two + * back-to-back fenced code blocks with no blank line between them) each + * anchor to their own occurrence instead of both collapsing onto the + * first. Descendants still search starting at their own parent's start, + * since a child's raw is a substring of its parent's and may begin at the + * same offset. + */ + const walk = (tokensArray: readonly marked.Token[], floor: number): number => { + for (const token of tokensArray) { + const found = content.indexOf(token.raw, floor); + if (found === -1) { + // Inner blockquote tokens omit their `> ` prefix and never appear + // verbatim; skip them rather than corrupt the running floor. + continue; + } + const rawEnd = found + token.raw.length; + visit(token, found, rawEnd); + + let childFloor = found; + if (token.type === "table") { + const table = token as marked.Tokens.Table; + for (const cell of table.header) { + childFloor = walk(cell.tokens, childFloor); + } + for (const row of table.rows) { + for (const cell of row) { + childFloor = walk(cell.tokens, childFloor); + } + } + } else if (token.type === "list") { + childFloor = walk((token as marked.Tokens.List).items, childFloor); + } else { + const children = (token as { tokens?: marked.Token[] }).tokens; + if (children) { + childFloor = walk(children, childFloor); + } + } + floor = Math.max(rawEnd, childFloor); + } + return floor; + }; + + walk(tokens, 0); + + return blocks; +}; + +/** + * Populate each section's {@link SectionNode.bodyChildren} from the top-level + * token stream. Top-level tokens tile the content region exactly, so a + * running raw-length offset gives exact spans (the `findHeadings` technique) + * with none of `findBlocks`' anchoring machinery, which exists only for + * descendants. `heading` and `space` tokens are structure, not body blocks; + * a paragraph that is nothing but an isolated `^id` marker line annotates the + * block above it and is likewise not counted — matching Obsidian's section + * cache, which omits such lines. + */ +const findBodyChildren = ( + content: string, + abs: Abs, + tokens: marked.TokensList, + root: SectionNode +): void => { + let offset = 0; + for (const token of tokens) { + const rawEnd = offset + token.raw.length; + if (token.type !== "heading" && token.type !== "space") { + const match = BLOCK_REFERENCE_REGEX.exec(token.raw); + const isIsolatedMarkerLine = match !== null && match.index === 0; + const end = stripTrailingEol(content, rawEnd, offset); + if (!isIsolatedMarkerLine && end > offset) { + const section = sectionContaining(root, abs(offset)); + section.bodyChildren.push({ + kind: token.type, + range: { start: abs(offset), end: abs(end) }, + }); + } + } + offset = rawEnd; + } +}; + +/** + * A raw heading in the source document that already ends with the exact + * reserved sequence used to disambiguate a duplicate sibling heading's + * address (see {@link disambiguatedHeadingText} in projection.ts) could + * collide with a synthesized address and silently resolve to the wrong + * section. This is checked once, here, so every consumer (map projection, + * read, patch) is protected uniformly rather than just the map endpoint. + * + * Block ids get no such check: {@link BLOCK_REFERENCE_REGEX} constrains a + * block id to `[a-zA-Z0-9_-]+`, which cannot contain these (astral, + * non-ASCII) codepoints in the first place. + */ +const assertNoReservedMarkerCollisions = (headings: HeadingSpan[]): void => { + for (const heading of headings) { + if (DUPLICATE_MARKER_SUFFIX.test(heading.text)) { + throw new ReservedDuplicateMarkerError( + `Heading "${heading.text}" ends with a sequence reserved for addressing duplicate headings; rename it to avoid ambiguous addressing.` + ); + } + } +}; + +const findLineEnding = (document: string): "\n" | "\r\n" => + document.indexOf("\r\n") > -1 ? "\r\n" : "\n"; + +const buildFrontmatter = ( + document: string, + frontmatterText: string | null, + contentOffset: number +): DocumentModel["frontmatter"] => { + if (frontmatterText === null) { + return { entries: [], block: null }; + } + const block: DocumentRange = { start: 0, end: contentOffset }; + + // Parse the inner YAML once as a positioned AST. Each pair node carries + // both the *parsed* key and real source ranges, so a quoted `"foo"`, a key + // containing a colon, or a numeric key all yield the same entry the values + // expose. (The hand-rolled line scan this replaces matched raw line text + // against parsed keys by string equality, so any key whose written form + // differed from its parsed form silently produced no entry — and, since + // frontmatter writes re-serialize from the entry list, was dropped from the + // document by the next edit.) + const doc = parseDocument(frontmatterText); + if (doc.errors.length > 0) { + throw new FrontmatterParseError( + `Could not parse document frontmatter: ${doc.errors[0].message}` + ); + } + if (!isMap(doc.contents)) { + // Empty, or not a mapping (e.g. a bare list): nothing key-addressable. + return { entries: [], block }; + } + + // Node ranges are offsets into the inner YAML text, which begins just past + // the opening delimiter line. + const innerStart = /^---(?:\r\n|\r|\n)/.exec(document)?.[0].length ?? 4; + + const entries: FrontmatterEntry[] = doc.contents.items.map((item) => { + const keyNode = item.key; + const valueNode = item.value; + const key = String(isScalar(keyNode) ? keyNode.value : keyNode.toJSON()); + const value: unknown = valueNode ? valueNode.toJSON() : null; + const keyRange = keyNode.range; + const valueRange = valueNode?.range ?? keyRange; + return { + key, + value, + entryRange: { + start: innerStart + keyRange[0], + end: innerStart + valueRange[1], + }, + valueRange: { + start: innerStart + (valueNode?.range?.[0] ?? keyRange[1]), + end: innerStart + valueRange[1], + }, + }; + }); + + return { entries, block }; +}; + +/** + * Build the internal document model: an ordered tree of sections (each owning + * its heading, direct body and trailing gap), a block overlay indexed into + * those sections, and the frontmatter. Every byte of the content region + * belongs to exactly one section's marker, content or trailingGap, so the tree + * losslessly partitions the document. + */ +export const buildModel = (document: string): DocumentModel => { + const { content, contentOffset, frontmatterText } = preProcess(document); + const { normalized, toOriginal } = normalizeLineEndings(content); + const abs: Abs = (n) => contentOffset + toOriginal[n]; + const tokens = new marked.Lexer().lex(normalized); + const headings = findHeadings(normalized, tokens); + const root = buildSectionTree(normalized, abs, headings); + findBlocks(normalized, abs, tokens, root); + findBodyChildren(normalized, abs, tokens, root); + assertNoReservedMarkerCollisions(headings); + + return { + version: versionOf(document), + lineEnding: findLineEnding(document), + frontmatter: buildFrontmatter(document, frontmatterText, contentOffset), + root, + }; +}; + +/** Iterate every section, root first, in document order. Exposed for tests. */ +export const eachSection = forEachSection; + +/** + * Reconstruct the source document from the section partition. Used to prove the + * tree tiles the content region losslessly. + */ +export const serializeModel = ( + document: string, + model: DocumentModel +): string => { + const frontmatter = model.frontmatter.block + ? document.slice(model.frontmatter.block.start, model.frontmatter.block.end) + : ""; + const parts: string[] = []; + const emit = (node: SectionNode): void => { + if (node.marker) { + parts.push(document.slice(node.marker.start, node.marker.end)); + } + parts.push(document.slice(node.body.start, node.body.end)); + parts.push(document.slice(node.trailingGap.start, node.trailingGap.end)); + for (const child of node.children) { + emit(child); + } + }; + emit(model.root); + return frontmatter + parts.join(""); +}; diff --git a/src/patch.ts b/src/patch.ts deleted file mode 100644 index 5296038..0000000 --- a/src/patch.ts +++ /dev/null @@ -1,772 +0,0 @@ -import { getDocumentMap } from "./map.js"; -import * as marked from "marked"; -import * as yaml from "yaml"; -import { - AppendTableRowsBlockPatchInstruction, - PrependTableRowsBlockPatchInstruction, - DocumentMap, - DocumentMapMarkerContentPair, - TextExtendingPatchInstruction, - PatchInstruction, - ReplaceTableRowsBlockPatchInstruction, - BaseHeadingPatchInstruction, - BaseBlockPatchInstruction, - AppendableFrontmatterType, - PatchTargetScope, -} from "./types.js"; -import { ContentType } from "./types.js"; -import { - isAppendableFrontmatterType, - isDictionary, - isList, - isString, - isStringArray, - isStringArrayArray, -} from "./typeGuards.js"; -import { DEFAULT_TARGET_SCOPE } from "./constants.js"; - -export enum PatchFailureReason { - InvalidTarget = "invalid-target", - ContentAlreadyPreexistsInTarget = "content-already-preexists-in-target", - TableContentIncorrectColumnCount = "table-content-incorrect-column-count", - ContentTypeInvalid = "content-type-invalid", - ContentTypeInvalidForTarget = "content-type-invalid-for-target", - ContentNotMergeable = "content-not-mergeable", -} - -const describeInstructionTarget = (instruction: PatchInstruction): string => { - if (instruction.targetType === "heading") { - const path = instruction.target; - return path === null || path.length === 0 - ? "document root" - : `heading "${path.join(" > ")}"`; - } - if (instruction.targetType === "block") { - return `block "^${instruction.target}"`; - } - return `frontmatter field "${instruction.target}"`; -}; - -const buildPatchFailedMessage = ( - reason: PatchFailureReason, - instruction: PatchInstruction -): string => { - const op = instruction.operation; - const target = describeInstructionTarget(instruction); - switch (reason) { - case PatchFailureReason.InvalidTarget: - return `Cannot ${op} ${target}: target not found in document`; - case PatchFailureReason.ContentAlreadyPreexistsInTarget: - return `Cannot ${op} ${target}: content already exists at target`; - case PatchFailureReason.TableContentIncorrectColumnCount: - return `Cannot ${op} ${target}: row column count does not match table`; - case PatchFailureReason.ContentTypeInvalid: - return `Cannot ${op} ${target}: content is not valid for this operation`; - case PatchFailureReason.ContentTypeInvalidForTarget: - return `Cannot ${op} ${target}: target block is not a table`; - case PatchFailureReason.ContentNotMergeable: - return `Cannot ${op} ${target}: content type is not compatible with merge`; - } -}; - -export class PatchFailed extends Error { - public reason: PatchFailureReason; - public instruction: PatchInstruction; - public targetMap: DocumentMapMarkerContentPair | null; - - constructor( - reason: PatchFailureReason, - instruction: PatchInstruction, - targetMap: DocumentMapMarkerContentPair | null - ) { - super(buildPatchFailedMessage(reason, instruction)); - this.reason = reason; - this.instruction = instruction; - this.targetMap = targetMap; - this.name = "PatchFailed"; - - Object.setPrototypeOf(this, new.target.prototype); - } -} - -export class PatchError extends Error {} - -export class MergeNotPossible extends Error {} - -const getEffectiveRange = ( - target: DocumentMapMarkerContentPair, - targetScope: PatchTargetScope = DEFAULT_TARGET_SCOPE -): { start: number; end: number } => { - if (targetScope === "marker") { - return { start: target.marker.start, end: target.marker.end }; - } - if (targetScope === "markerAndContent") { - return { - start: Math.min(target.marker.start, target.content.start), - end: Math.max(target.marker.end, target.content.end), - }; - } - return { start: target.content.start, end: target.content.end }; -}; - -const replaceText = ( - document: string, - instruction: PatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - const targetScope = "targetScope" in instruction ? instruction.targetScope : DEFAULT_TARGET_SCOPE; - const { start, end } = getEffectiveRange(target, targetScope); - const suffix = document.slice(end); - // Some block tokens (e.g. tables) have their raw include the blank-line - // separator, so content.end lands on that separator \n rather than just - // after the content's own trailing \n. The suffix then starts with only - // one \n instead of the two that form the blank line. Restore the missing - // newline so the blank line is preserved when the replacement has no - // trailing newline of its own. - const lineEnding = suffix.startsWith("\r\n") ? "\r\n" : "\n"; - const hasSingleLeadingNewline = - suffix.startsWith(lineEnding) && !suffix.startsWith(lineEnding + lineEnding); - const docLineEnding = document.indexOf("\r\n") > -1 ? "\r\n" : "\n"; - // For heading sections, content.end sits right at the start of the next - // heading, so the blank-line separator between sections is inside the - // content region (not in the suffix). If the original document had a - // blank line immediately before content.end, preserve it after the - // replacement. - const hadTrailingBlankLine = - suffix.length > 0 && - !suffix.startsWith(docLineEnding) && - end >= docLineEnding.length * 2 && - document.slice( - end - docLineEnding.length * 2, - end - ) === docLineEnding + docLineEnding; - - let content = instruction.content; - if (typeof content === "string") { - if (hasSingleLeadingNewline && !content.endsWith("\n") && !content.endsWith("\r\n")) { - content = content + lineEnding; - } else if (hadTrailingBlankLine && !content.endsWith(docLineEnding + docLineEnding)) { - content = content.endsWith(docLineEnding) - ? content + docLineEnding - : content + docLineEnding + docLineEnding; - } - } - return [ - document.slice(0, start), - content, - suffix, - ].join(""); -}; - -const prependText = ( - document: string, - instruction: TextExtendingPatchInstruction & PatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - const targetScope = "targetScope" in instruction ? instruction.targetScope : DEFAULT_TARGET_SCOPE; - const { start } = getEffectiveRange(target, targetScope); - return [ - document.slice(0, start), - instruction.content, - instruction.trimTargetWhitespace - ? document.slice(start).trimStart() - : document.slice(start), - ].join(""); -}; - -const appendText = ( - document: string, - instruction: TextExtendingPatchInstruction & PatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - const targetScope = "targetScope" in instruction ? instruction.targetScope : DEFAULT_TARGET_SCOPE; - const { end } = getEffectiveRange(target, targetScope); - const suffix = document.slice(end); - const lineEnding = document.indexOf("\r\n") > -1 ? "\r\n" : "\n"; - // For heading sections, content.end sits right at the start of the next - // heading, so the blank-line separator between sections is inside the - // content region and ends up *before* the appended content. Detect this - // by checking for a trailing blank line immediately before content.end and, - // unless the caller explicitly trimmed target whitespace, restore the - // separator *after* the new content instead. - const hadTrailingBlankLine = - !instruction.trimTargetWhitespace && - suffix.length > 0 && - !suffix.startsWith(lineEnding) && - end >= lineEnding.length * 2 && - document.slice( - end - lineEnding.length * 2, - end - ) === lineEnding + lineEnding; - - let content = instruction.content; - if ( - hadTrailingBlankLine && - typeof content === "string" && - !content.endsWith(lineEnding + lineEnding) - ) { - content = content.endsWith(lineEnding) - ? content + lineEnding - : content + lineEnding + lineEnding; - } - - if (instruction.trimTargetWhitespace) { - return [ - document.slice(0, end).trimEnd(), - content, - suffix, - ].join(""); - } - - // When this is the last section (suffix is empty), trailing blank lines in - // the content region are just document-level whitespace, not section - // separators. Strip them so the new content follows directly without a - // visual gap. - let insertionEnd = end; - if (suffix.length === 0) { - const doubleEnding = lineEnding + lineEnding; - while ( - insertionEnd >= lineEnding.length * 2 && - document.slice(insertionEnd - lineEnding.length * 2, insertionEnd) === - doubleEnding - ) { - insertionEnd -= lineEnding.length; - } - } - - return [document.slice(0, insertionEnd), content, suffix].join(""); -}; - -export class TablePartsNotFound extends Error {} - -const _getTableData = ( - document: string, - target: DocumentMapMarkerContentPair -): { - token: marked.Tokens.Table; - lineEnding: string; - headerParts: string; - contentParts: string; -} => { - const targetTable = document.slice(target.content.start, target.content.end); - const tableToken = marked.lexer(targetTable)[0]; - const match = /^(.*?)(?:\r?\n)(.*?)(\r?\n)/.exec(targetTable); - if (!(tableToken.type === "table") || !match) { - throw new TablePartsNotFound(); - } - - const lineEnding = match[3]; - return { - token: tableToken as marked.Tokens.Table, - lineEnding: match[3], - headerParts: match[1] + lineEnding + match[2] + lineEnding, - contentParts: targetTable.slice(match[0].length), - }; -}; - -const replaceTable = ( - document: string, - instruction: ReplaceTableRowsBlockPatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - try { - const table = _getTableData(document, target); - const tableRows: string[] = [table.headerParts]; - let content = instruction.content; - if (isStringArray(content)) { - // For when the request sends in just a single row - content = [content]; - } - if (isStringArrayArray(content)) { - // For when the incoming request is multiple rows - for (const row of content) { - if ( - row.length !== table.token.header.length || - typeof row === "string" - ) { - throw new PatchFailed( - PatchFailureReason.TableContentIncorrectColumnCount, - instruction, - target - ); - } - - tableRows.push("| " + row.join(" | ") + " |" + table.lineEnding); - } - } else { - throw new PatchFailed( - PatchFailureReason.ContentTypeInvalid, - instruction, - target - ); - } - - return [ - document.slice(0, target.content.start), - tableRows.join(""), - document.slice(target.content.end), - ].join(""); - } catch (TablePartsNotFound) { - throw new PatchFailed( - PatchFailureReason.ContentTypeInvalidForTarget, - instruction, - target - ); - } -}; - -const prependTable = ( - document: string, - instruction: PrependTableRowsBlockPatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - try { - const table = _getTableData(document, target); - const tableRows: string[] = [table.headerParts]; - let content = instruction.content; - if (isStringArray(content)) { - // For when the request sends in just a single row - content = [content]; - } - if (isStringArrayArray(content)) { - // For when the request sends in just a single row - if (instruction.rejectIfContentPreexists) { - const existingRows = table.token.rows.map((row) => - row.map((cell) => cell.text) - ); - const allPreexist = content.every((incomingRow) => - existingRows.some( - (existingRow) => - existingRow.length === incomingRow.length && - existingRow.every((cell, i) => cell === incomingRow[i]) - ) - ); - if (allPreexist) { - throw new PatchFailed( - PatchFailureReason.ContentAlreadyPreexistsInTarget, - instruction, - target - ); - } - } - for (const row of content) { - if ( - row.length !== table.token.header.length || - typeof row === "string" - ) { - throw new PatchFailed( - PatchFailureReason.TableContentIncorrectColumnCount, - instruction, - target - ); - } - - tableRows.push("| " + row.join(" | ") + " |" + table.lineEnding); - } - } else { - throw new PatchFailed( - PatchFailureReason.ContentTypeInvalid, - instruction, - target - ); - } - - tableRows.push(table.contentParts); - - return [ - document.slice(0, target.content.start), - tableRows.join(""), - document.slice(target.content.end), - ].join(""); - } catch (TablePartsNotFound) { - throw new PatchFailed( - PatchFailureReason.ContentTypeInvalidForTarget, - instruction, - target - ); - } -}; - -const appendTable = ( - document: string, - instruction: AppendTableRowsBlockPatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - try { - const table = _getTableData(document, target); - const tableRows: string[] = [table.headerParts, table.contentParts]; - let content = instruction.content; - if (isStringArray(content)) { - // For when the request sends in just a single row - content = [content]; - } - if (isStringArrayArray(content)) { - // For when the incoming request is multiple rows - if (instruction.rejectIfContentPreexists) { - const existingRows = table.token.rows.map((row) => - row.map((cell) => cell.text) - ); - const allPreexist = content.every((incomingRow) => - existingRows.some( - (existingRow) => - existingRow.length === incomingRow.length && - existingRow.every((cell, i) => cell === incomingRow[i]) - ) - ); - if (allPreexist) { - throw new PatchFailed( - PatchFailureReason.ContentAlreadyPreexistsInTarget, - instruction, - target - ); - } - } - for (const row of content) { - if ( - row.length !== table.token.header.length || - typeof row === "string" - ) { - throw new PatchFailed( - PatchFailureReason.TableContentIncorrectColumnCount, - instruction, - target - ); - } - - tableRows.push("| " + row.join(" | ") + " |" + table.lineEnding); - } - } else { - throw new PatchFailed( - PatchFailureReason.ContentTypeInvalid, - instruction, - target - ); - } - - return [ - document.slice(0, target.content.start), - tableRows.join(""), - document.slice(target.content.end), - ].join(""); - } catch (TablePartsNotFound) { - throw new PatchFailed( - PatchFailureReason.ContentTypeInvalidForTarget, - instruction, - target - ); - } -}; - -const replace = ( - document: string, - instruction: PatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - const contentType = - "contentType" in instruction && instruction.contentType - ? instruction.contentType - : ContentType.text; - - switch (contentType) { - case ContentType.text: - return replaceText(document, instruction, target); - case ContentType.json: - return replaceTable( - document, - instruction as ReplaceTableRowsBlockPatchInstruction, - target - ); - default: - throw new PatchError(`Unsupported contentType: ${contentType}`); - } -}; - -const prepend = ( - document: string, - instruction: TextExtendingPatchInstruction & PatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - const contentType = - "contentType" in instruction && instruction.contentType - ? instruction.contentType - : ContentType.text; - - switch (contentType) { - case ContentType.text: - return prependText(document, instruction, target); - case ContentType.json: - return prependTable( - document, - instruction as PrependTableRowsBlockPatchInstruction, - target - ); - default: - throw new PatchError(`Unsupported contentType: ${contentType}`); - } -}; - -const append = ( - document: string, - instruction: TextExtendingPatchInstruction & PatchInstruction, - target: DocumentMapMarkerContentPair -): string => { - const contentType = - "contentType" in instruction && instruction.contentType - ? instruction.contentType - : ContentType.text; - - switch (contentType) { - case ContentType.text: - return appendText(document, instruction, target); - case ContentType.json: - return appendTable( - document, - instruction as AppendTableRowsBlockPatchInstruction, - target - ); - default: - throw new PatchError(`Unsupported contentType: ${contentType}`); - } -}; - -const addTargetHeading = ( - document: string, - instruction: TextExtendingPatchInstruction & - PatchInstruction & - BaseHeadingPatchInstruction, - map: DocumentMap -): string => { - const elements: string[] = []; - let bestTarget = map.heading[""]; - for (const element of instruction.target ?? []) { - const possibleMatch = map.heading[[...elements, element].join("\u001f")]; - if (possibleMatch) { - elements.push(element); - bestTarget = possibleMatch; - } else { - break; - } - } - let finalContent = ""; - let existingLevels = elements.length; - if ( - document.slice( - bestTarget.content.end - map.lineEnding.length, - bestTarget.content.end - ) !== map.lineEnding - ) { - finalContent += map.lineEnding; - } - for (const headingPart of (instruction.target ?? []).slice(existingLevels)) { - existingLevels += 1; - finalContent += `${"#".repeat(existingLevels)} ${headingPart}${map.lineEnding}`; - } - finalContent += instruction.content; - - return [ - document.slice(0, bestTarget.content.end), - finalContent, - document.slice(bestTarget.content.end), - ].join(""); -}; - -const addTargetBlock = ( - document: string, - instruction: TextExtendingPatchInstruction & - PatchInstruction & - BaseBlockPatchInstruction, - map: DocumentMap -): string => { - return ( - document + - map.lineEnding + - instruction.content + - map.lineEnding + - map.lineEnding + - "^" + - instruction.target - ); -}; - -const addTarget = ( - document: string, - instruction: TextExtendingPatchInstruction & - PatchInstruction & - (BaseBlockPatchInstruction | BaseHeadingPatchInstruction), - map: DocumentMap -): string => { - switch (instruction.targetType) { - case "heading": - return addTargetHeading(document, instruction, map); - case "block": - return addTargetBlock(document, instruction, map); - } -}; - -const getTarget = ( - map: DocumentMap, - instruction: PatchInstruction -): DocumentMapMarkerContentPair | undefined => { - switch (instruction.targetType) { - case "heading": - return map.heading[ - instruction.target ? instruction.target.join("\u001f") : "" - ]; - case "block": - return map.block[instruction.target]; - case "frontmatter": - return map.frontmatter[instruction.target]; - } -}; - -function mergeFrontmatterValue( - obj1: AppendableFrontmatterType, - obj2: AppendableFrontmatterType -): AppendableFrontmatterType { - if (isList(obj1) && isList(obj2)) { - return [...obj1, ...obj2]; - } else if (isDictionary(obj1) && isDictionary(obj2)) { - return { ...obj1, ...obj2 }; - } else if (isString(obj1) && isString(obj2)) { - return obj1 + obj2; - } - - throw new MergeNotPossible(); -} - -function regenerateDocumentWithFrontmatter( - frontmatter: Record, - document: string, - map: DocumentMap -): string { - const rawFrontmatterText = Object.values(frontmatter).some( - (value) => value !== undefined - ) - ? `---\n${yaml.stringify(frontmatter).trimEnd()}\n---\n` - : ""; - - const frontmatterText = - map.lineEnding !== "\n" - ? rawFrontmatterText.replaceAll("\n", map.lineEnding) - : rawFrontmatterText; - const finalDocument = document.slice(map.contentOffset); - - return frontmatterText + finalDocument; -} - -/** - * Applies a patch to the specified document. - * - * @param document The document to apply the patch to. - * @param instruction The patch to apply. - * @returns The patched document - */ -export const applyPatch = ( - document: string, - instruction: PatchInstruction -): string => { - const map = getDocumentMap(document); - const target = getTarget(map, instruction); - - if ( - instruction.targetType === "block" || - instruction.targetType === "heading" - ) { - if (!target) { - if (instruction.createTargetIfMissing) { - return addTarget(document, instruction, map); - } else { - throw new PatchFailed( - PatchFailureReason.InvalidTarget, - instruction, - null - ); - } - } - if ( - instruction.operation !== "replace" && - "rejectIfContentPreexists" in instruction && - instruction.rejectIfContentPreexists && - typeof instruction.content === "string" && - (() => { - const { start, end } = getEffectiveRange( - target, - "targetScope" in instruction ? instruction.targetScope : DEFAULT_TARGET_SCOPE - ); - return document.slice(start, end).includes(instruction.content.trim()); - })() - ) { - throw new PatchFailed( - PatchFailureReason.ContentAlreadyPreexistsInTarget, - instruction, - target - ); - } - switch (instruction.operation) { - case "append": - return append(document, instruction, target); - case "prepend": - return prepend(document, instruction, target); - case "replace": - return replace(document, instruction, target); - default: - throw new PatchError("Invalid operation"); - } - } - const frontmatter = { ...map.frontmatter }; - - if (frontmatter[instruction.target] === undefined) { - if (instruction.createTargetIfMissing) { - if (isList(instruction.content)) { - frontmatter[instruction.target] = []; - } else if (isString(instruction.content)) { - frontmatter[instruction.target] = ""; - } else if (isDictionary(instruction.content)) { - frontmatter[instruction.target] = {}; - } - } else { - throw new PatchFailed( - PatchFailureReason.InvalidTarget, - instruction, - null - ); - } - } - - try { - switch (instruction.operation) { - case "append": - if (!isAppendableFrontmatterType(instruction.content)) { - throw new MergeNotPossible(); - } - frontmatter[instruction.target] = mergeFrontmatterValue( - frontmatter[instruction.target], - instruction.content - ); - break; - case "prepend": - if (!isAppendableFrontmatterType(instruction.content)) { - throw new MergeNotPossible(); - } - frontmatter[instruction.target] = mergeFrontmatterValue( - instruction.content, - frontmatter[instruction.target] - ); - break; - case "replace": - frontmatter[instruction.target] = instruction.content; - break; - } - - return regenerateDocumentWithFrontmatter(frontmatter, document, map); - } catch (error) { - if (error instanceof MergeNotPossible) { - throw new PatchFailed( - PatchFailureReason.ContentNotMergeable, - instruction, - null - ); - } - throw error; - } -}; diff --git a/src/projection.ts b/src/projection.ts new file mode 100644 index 0000000..7127904 --- /dev/null +++ b/src/projection.ts @@ -0,0 +1,197 @@ +import { BlockNode, DocumentModel, SectionNode } from "./model.js"; +import { DUPLICATE_DIGITS, DUPLICATE_MARKER } from "./constants.js"; + +/** + * A nested map of heading text to its child headings, mirroring the document's + * section nesting. A leaf heading maps to an empty object. The tree carries no + * heading levels: nesting is by containment, so a level skipped in the source + * (an `h1` followed directly by an `h3`) does not appear as a hole — the engine + * owns depth and a consumer never needs it. + * + * Sibling headings are keyed by text, and every occurrence gets its own key: a + * repeated sibling name is not merged. The first occurrence keeps its plain + * text; each later occurrence's key has a {@link disambiguatedHeadingText} + * suffix appended, so no two sections ever share an address. For + * `## Log / ### Monday` followed by `## Log / ### Tuesday`, the tree is + * `{ Log: { Monday: {} }, "Log": { Tuesday: {} } }`: both "Log"s, and + * both children, are separately addressable. + * + * The tree therefore enumerates exactly the addresses the resolver accepts — + * see {@link headingTreePaths}. + */ +export interface HeadingTree { + [headingText: string]: HeadingTree; +} + +/** Encode a 0-based occurrence index as hex, one reserved digit per hex character. */ +const encodeOccurrenceSuffix = (occurrenceIndex: number): string => + occurrenceIndex + .toString(16) + .split("") + .map((hexChar) => DUPLICATE_DIGITS[parseInt(hexChar, 16)]) + .join(""); + +/** + * The text a heading-bearing section is keyed/addressed by: its raw heading + * text for the first occurrence among same-text siblings under the same + * parent, or that text plus a reserved-codepoint suffix for each later + * occurrence (in document order). This is the single source of truth both + * {@link headingPath} (resolution) and {@link projectMap} (the map a caller + * reads) derive from, so the two can never drift apart — an address is always + * matched by plain string equality against a freshly recomputed value, never + * decoded. Keys produced here are guaranteed unique among a parent's children + * as long as no raw heading text already collides with a synthesized suffix, + * which {@link buildModel} guards against. + */ +export const disambiguatedHeadingText = (node: SectionNode): string => { + const heading = node.heading; + if (!heading) { + return ""; + } + if (!node.parent) { + return heading.text; + } + const siblings = node.parent.children.filter( + (sibling) => sibling.heading?.text === heading.text + ); + const occurrence = siblings.indexOf(node); + return occurrence <= 0 + ? heading.text + : heading.text + DUPLICATE_MARKER + encodeOccurrenceSuffix(occurrence - 1); +}; + +/** + * Every block in the document, in document order (a pre-order walk of the + * section tree). Blocks are addressed globally by id, not scoped to a parent + * the way headings are scoped to sibling groups, so disambiguating a + * duplicate id needs this full document-order list rather than a single + * node's local neighbors. + */ +export const allBlocksInOrder = (root: SectionNode): BlockNode[] => { + const blocks: BlockNode[] = []; + const walk = (node: SectionNode): void => { + blocks.push(...node.blocks); + for (const child of node.children) { + walk(child); + } + }; + walk(root); + return blocks; +}; + +/** + * The id a block is keyed/addressed by: its raw id for the first occurrence + * among every block in the document sharing that id, or that id plus a + * reserved-codepoint suffix for each later occurrence (in document order). + * Mirrors {@link disambiguatedHeadingText}'s role for headings — both + * {@link projectMap} and the resolver derive from this one function, so an + * address is always matched by plain string equality, never decoded. A raw + * block id cannot itself collide with a synthesized suffix: block ids are + * constrained to `[a-zA-Z0-9_-]+` by {@link BLOCK_REFERENCE_REGEX} in + * model.ts, which cannot contain these (astral, non-ASCII) codepoints, so no + * buildModel-time guard is needed here the way there is for headings. + */ +export const disambiguatedBlockId = ( + block: BlockNode, + allBlocks: readonly BlockNode[] +): string => { + const sameId = allBlocks.filter((candidate) => candidate.id === block.id); + const occurrence = sameId.indexOf(block); + return occurrence <= 0 + ? block.id + : block.id + DUPLICATE_MARKER + encodeOccurrenceSuffix(occurrence - 1); +}; + +/** + * The terse, context-cheap public view of a document, derived from the + * {@link DocumentModel}. It carries no in-band grammar: headings nest by + * containment in a {@link HeadingTree} and block references are bare ids. + */ +export interface PublicMap { + /** Content-hash token; pass back as an `ifMatch` precondition. */ + version: string; + /** Top-level frontmatter field names, in document order. */ + frontmatterFields: string[]; + /** Headings nested by containment; see {@link HeadingTree}. */ + headings: HeadingTree; + /** + * Block reference ids, bare (no `^`), in document order. A duplicate id + * gets its own entry per occurrence: the first keeps its bare id, and each + * later occurrence's entry has a {@link disambiguatedBlockId} suffix + * appended, so no two entries are ever the same string. + */ + blocks: string[]; +} + +/** + * The containment path of a heading-bearing section: the ancestor heading texts + * from the top level down to this node (e.g. `["Overview", "Details"]`), one + * entry per heading on the path regardless of source level. This is exactly the + * address a consumer sends back as a heading target. Shared with the resolver + * so map addresses and target matching use one definition. + */ +export const headingPath = (node: SectionNode): string[] => { + const path: string[] = []; + let current: SectionNode | null = node; + while (current && current.heading) { + path.push(disambiguatedHeadingText(current)); + current = current.parent; + } + return path.reverse(); +}; + +/** Project the internal model into the public map consumers receive. */ +export const projectMap = (model: DocumentModel): PublicMap => { + // Null-prototype: a heading literally named "__proto__" must become a real + // own key. On an ordinary object literal, `into[text] = subtree` for that + // text invokes Object.prototype's `__proto__` setter instead, silently + // discarding the heading. + const headings: HeadingTree = Object.create(null) as HeadingTree; + + // Blocks are addressed globally by (possibly disambiguated) id, so every + // block is listed in document order — including any under a heading + // shadowed by a duplicate. + const allBlocks = allBlocksInOrder(model.root); + const blocks = allBlocks.map((block) => disambiguatedBlockId(block, allBlocks)); + + // Headings nest by containment. Every occurrence of a repeated sibling name + // gets its own key via disambiguatedHeadingText, so — given buildModel's + // collision guard — keys are unique by construction and no merge is needed. + const buildTree = (node: SectionNode, into: HeadingTree): void => { + for (const child of node.children) { + if (!child.heading) { + continue; + } + const key = disambiguatedHeadingText(child); + const subtree: HeadingTree = Object.create(null) as HeadingTree; + into[key] = subtree; + buildTree(child, subtree); + } + }; + buildTree(model.root, headings); + + return { + version: model.version, + frontmatterFields: model.frontmatter.entries.map((entry) => entry.key), + headings, + blocks, + }; +}; + +/** + * Enumerate every addressable heading in a {@link HeadingTree} as its + * containment-path target, in document order. This is the walk a consumer runs + * to turn a map into the list of heading addresses it can patch. + */ +export const headingTreePaths = (tree: HeadingTree): string[][] => { + const paths: string[][] = []; + const walk = (node: HeadingTree, prefix: string[]): void => { + for (const [text, children] of Object.entries(node)) { + const path = [...prefix, text]; + paths.push(path); + walk(children, path); + } + }; + walk(tree, []); + return paths; +}; diff --git a/src/ranges.ts b/src/ranges.ts new file mode 100644 index 0000000..5a873f9 --- /dev/null +++ b/src/ranges.ts @@ -0,0 +1,69 @@ +/** + * Range geometry over model nodes: turn a resolved node into the byte spans an + * operation acts on. Both `content` and `markerAndContent` act on a section's + * whole *subtree* — which is contiguous in document order because a section's + * descendants immediately follow its content and gap — the difference being that + * `content` excludes the heading line itself (the subtree *minus* its own + * marker) while `markerAndContent` includes it. + */ + +import { DocumentRange } from "./types.js"; +import { BlockNode, SectionNode } from "./model.js"; + +/** The deepest, last node within a section's subtree (the section itself if a leaf). */ +export const lastDescendant = (section: SectionNode): SectionNode => + section.children.length > 0 + ? lastDescendant(section.children[section.children.length - 1]) + : section; + +const subtreeStart = (section: SectionNode): number => + section.marker ? section.marker.start : section.body.start; + +/** + * The subtree's visible extent: the heading line through the last descendant's + * content, excluding the final separator gap. Used for whole-section replace + * and sibling-insert anchoring. + */ +export const subtreeContentRange = (section: SectionNode): DocumentRange => ({ + start: subtreeStart(section), + end: lastDescendant(section).trailingGap.start, +}); + +/** + * The `content` scope of a heading: everything below the heading line through + * the last descendant's content, excluding the heading line itself and the final + * trailing gap. This is the whole subtree *minus* its own marker — the span 1.x + * `content` addressed — so a single content read/replace round-trips a section's + * full body, subsections included. For a leaf section it coincides with the + * direct body (`section.body`). + */ +export const headingContentRange = (section: SectionNode): DocumentRange => ({ + start: section.body.start, + end: lastDescendant(section).trailingGap.start, +}); + +/** The subtree's end including its trailing separator gap; used for clean deletes. */ +export const subtreeEnd = (section: SectionNode): number => + lastDescendant(section).trailingGap.end; + +export class RootHasNoMarkerError extends Error {} + +/** The heading label line span; throws for the markerless document root. */ +export const headingMarkerRange = (section: SectionNode): DocumentRange => { + if (!section.marker) { + throw new RootHasNoMarkerError("the document root has no marker to address"); + } + return section.marker; +}; + +/** The block's addressable text (the preceding block, for an isolated `^id`). */ +export const blockContentRange = (block: BlockNode): DocumentRange => block.content; + +/** The `^id` token span. */ +export const blockMarkerRange = (block: BlockNode): DocumentRange => block.marker; + +/** The whole block: content through marker (they are contiguous for inline ids). */ +export const blockFullRange = (block: BlockNode): DocumentRange => ({ + start: Math.min(block.content.start, block.marker.start), + end: Math.max(block.content.end, block.marker.end), +}); diff --git a/src/read.ts b/src/read.ts new file mode 100644 index 0000000..3a5d1f7 --- /dev/null +++ b/src/read.ts @@ -0,0 +1,148 @@ +/** + * Targeted reads over the 2.0 model. The mirror image of {@link patch}: an + * address (the same `(targetType, target)` pair a patch instruction carries) + * resolves to a node, and the node's addressable value comes back. An optional + * `scope` mirrors the patch scopes, with one invariant tying the two together: + * *read @ scope S, then `replace` @ scope S with the value unchanged, is a + * no-op.* + * + * - `content` (the default): a heading's body de-levelled by the target's own + * level, a block's literal text, a frontmatter key's parsed value. + * - `marker`: the node's label — a heading's raw text (no `#`s, and no + * duplicate-disambiguation suffix), a block's bare id, a frontmatter key. + * - `markerAndContent`: the whole node — a heading's subtree re-levelled to + * its *parent's* baseline (its own heading line reads as `# Title`, exactly + * the shape a `markerAndContent` replace consumes), a block's full span + * including its `^id`, a frontmatter entry as a single-key object. + * + * The frontmatter `markerAndContent` read is the one deviation from the + * round-trip invariant: it returns the entry as `{key: value}` — the shape a + * `markerAndContent` *prepend/append* consumes — because on the write side a + * frontmatter `replace` carries a plain value at either scope, so a strict + * mirror would just duplicate the `content` read. + * + * A heading's `content` is de-levelled by the target's own level, mirroring + * the baseline a `content`-scope write rebases *up* by (see `levels.ts`). + * Without this, a heading's content round-trips through a content-scope write + * at the wrong depth: reading section "# Overview"'s "## Details" child and + * writing it straight back would rebase it to "### Details". + */ + +import { buildModel } from "./model.js"; +import { resolveTarget, Addressed } from "./resolve.js"; +import { + headingContentRange, + blockContentRange, + blockFullRange, + subtreeContentRange, +} from "./ranges.js"; +import { relevelText } from "./text.js"; +import { InvalidInstructionError, TargetNotFoundError } from "./instructions.js"; + +/** The scopes a read supports — the value-bearing subset of the patch scopes. */ +export type ReadScope = "content" | "marker" | "markerAndContent"; + +/** The address of a read: the addressing subset a patch instruction uses, + * plus an optional {@link ReadScope} (default `content`). */ +export type ReadTarget = Addressed & { scope?: ReadScope }; + +/** The result of {@link readTarget}: markdown text, or a parsed frontmatter value. */ +export type ReadResult = + | { kind: "heading" | "block"; content: string } + | { kind: "frontmatter"; value: unknown }; + +/** + * Resolve `target` against `document` and return the addressed value at the + * requested scope (default `content`). Throws {@link TargetNotFoundError} + * when the address does not resolve — or for a `marker` read of the markerless + * document root — and {@link InvalidInstructionError} for a `within` read at a + * non-`content` scope (a positional body block has no marker of its own; its + * `markerAndContent` cells are insert-only on the write side). + */ +const READ_SCOPES: readonly ReadScope[] = [ + "content", + "marker", + "markerAndContent", +]; + +export const readTarget = (document: string, target: ReadTarget): ReadResult => { + const scope: ReadScope = target.scope ?? "content"; + if (!READ_SCOPES.includes(scope)) { + // Untyped callers (the CLI, HTTP layers) pass scope through as a string; + // an unrecognized one must not silently read as `content`. + throw new InvalidInstructionError( + `invalid read scope ${JSON.stringify(scope)}; expected one of ${READ_SCOPES.join(", ")}` + ); + } + const model = buildModel(document); + const resolved = resolveTarget(model, target); + if (!resolved) { + throw new TargetNotFoundError( + `Target not found: ${target.targetType} ${JSON.stringify(target.target)}` + ); + } + switch (resolved.kind) { + case "heading": { + const section = resolved.section; + if (scope === "marker") { + if (!section.heading) { + throw new TargetNotFoundError( + "the document root has no marker to read" + ); + } + return { kind: "heading", content: section.heading.text }; + } + if (scope === "markerAndContent") { + const range = subtreeContentRange(section); + const raw = document.slice(range.start, range.end); + // The parent's level is the baseline a markerAndContent write rebases + // up by, so the section's own heading reads as `# Title`. + const baseline = section.parent?.heading?.level ?? 0; + const content = + baseline === 0 ? raw : relevelText(raw, -baseline, model.lineEnding).text; + return { kind: "heading", content }; + } + const range = headingContentRange(section); + const raw = document.slice(range.start, range.end); + const baseline = section.heading?.level ?? 0; + // Baseline 0 (the document root) needs no releveling; skip it so a root + // read stays a byte-identical slice rather than a normalize/reapply round + // trip through relevelText. + const content = + baseline === 0 ? raw : relevelText(raw, -baseline, model.lineEnding).text; + return { kind: "heading", content }; + } + case "headingChild": { + if (scope !== "content") { + throw new InvalidInstructionError( + "a `within` read supports only the `content` scope: a positional body block has no marker of its own" + ); + } + // A body child can contain no heading (headings are structure, not + // children), so the slice is returned literally — no releveling. + const { start, end } = resolved.child.range; + return { kind: "heading", content: document.slice(start, end) }; + } + case "block": { + const block = resolved.block; + if (scope === "marker") { + return { kind: "block", content: block.id }; + } + const range = + scope === "markerAndContent" + ? blockFullRange(block) + : blockContentRange(block); + return { kind: "block", content: document.slice(range.start, range.end) }; + } + case "frontmatter": { + const entry = resolved.entry; + if (scope === "marker") { + return { kind: "frontmatter", value: entry.key }; + } + if (scope === "markerAndContent") { + return { kind: "frontmatter", value: { [entry.key]: entry.value } }; + } + return { kind: "frontmatter", value: entry.value }; + } + } +}; diff --git a/src/resolve.ts b/src/resolve.ts new file mode 100644 index 0000000..63509a6 --- /dev/null +++ b/src/resolve.ts @@ -0,0 +1,154 @@ +/** + * Turn a public target address back into the model node it names. Headings are + * matched by their containment path (see {@link headingPath}) — the ancestor + * heading texts from the top level down, ignoring source depth — so a skipped + * level needs no annotation. A duplicate sibling heading no longer collides + * with an earlier one: {@link headingPath} disambiguates each occurrence past + * the first, so every heading-bearing section has its own unique address and + * `.find()` below matches at most one. The `ifMatch` precondition still guards + * against staleness between reading a target and applying an edit. + */ + +import { + BlockNode, + BodyChild, + DocumentModel, + FrontmatterEntry, + SectionNode, + eachSection, +} from "./model.js"; +import { allBlocksInOrder, disambiguatedBlockId, headingPath } from "./projection.js"; +import { + HeadingAddress, + InvalidInstructionError, + TargetNotFoundError, + TargetType, +} from "./instructions.js"; + +export type ResolvedTarget = + | { kind: "heading"; section: SectionNode } + | { kind: "headingChild"; section: SectionNode; child: BodyChild; index: number } + | { kind: "block"; block: BlockNode } + | { kind: "frontmatter"; entry: FrontmatterEntry }; + +const arrayEquals = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((value, index) => value === b[index]); + +/** Every heading-bearing section, in document order. */ +const headingSections = (model: DocumentModel): SectionNode[] => { + const sections: SectionNode[] = []; + eachSection(model.root, (node) => { + if (node.heading) { + sections.push(node); + } + }); + return sections; +}; + +/** Resolve a heading address to its section (root for `null`/`[]`), or `null`. */ +export const resolveHeading = ( + model: DocumentModel, + address: HeadingAddress +): { kind: "heading"; section: SectionNode } | null => { + const target = address ?? []; + if (target.length === 0) { + return { kind: "heading", section: model.root }; + } + const sections = headingSections(model); + + // Match by containment path, ignoring source depth, so a plain address finds + // its section even across a skipped heading level. Disambiguated addresses + // make every section's path unique, so `.find()` matches at most one. + const match = sections.find((section) => + arrayEquals(headingPath(section), target) + ); + return match ? { kind: "heading", section: match } : null; +}; + +/** + * Resolve a block address to its block node, or `null`. Mirrors + * resolveHeading: a duplicate id's later occurrence is disambiguated (see + * {@link disambiguatedBlockId}), so recomputing every block's address and + * matching by plain string equality resolves at most one candidate. + */ +export const resolveBlock = ( + model: DocumentModel, + id: string +): { kind: "block"; block: BlockNode } | null => { + const blocks = allBlocksInOrder(model.root); + const match = blocks.find((block) => disambiguatedBlockId(block, blocks) === id); + return match ? { kind: "block", block: match } : null; +}; + +/** Resolve a frontmatter key to its entry, or `null`. */ +export const resolveFrontmatter = ( + model: DocumentModel, + key: string +): { kind: "frontmatter"; entry: FrontmatterEntry } | null => { + const entry = model.frontmatter.entries.find((candidate) => candidate.key === key); + return entry ? { kind: "frontmatter", entry } : null; +}; + +/** + * Refine a resolved section to one of its direct body's top-level blocks by + * position (negative counts from the end). Out-of-range throws + * {@link TargetNotFoundError} — the address names a block that does not exist, + * distinct from the `null` a missing *heading* returns so that + * `createTargetIfMissing` heading semantics stay untouched. + */ +export const resolveWithin = ( + section: SectionNode, + within: number +): Extract => { + if (!Number.isInteger(within)) { + // The schema rejects this before patch() ever resolves; guard direct + // resolver/readTarget callers too. + throw new InvalidInstructionError( + `\`within\` must be an integer; got ${JSON.stringify(within)}` + ); + } + const children = section.bodyChildren; + const index = within < 0 ? children.length + within : within; + if (index < 0 || index >= children.length) { + const sectionName = section.heading + ? JSON.stringify(headingPath(section)) + : "the document root"; + throw new TargetNotFoundError( + `\`within\` index ${within} is out of range: ${sectionName} has ${children.length} top-level block${children.length === 1 ? "" : "s"}` + ); + } + return { kind: "headingChild", section, child: children[index], index }; +}; + +/** The addressing subset of an instruction the resolver needs. */ +export type Addressed = + | { targetType: "heading"; target: HeadingAddress; within?: number } + | { targetType: "block"; target: string } + | { targetType: "frontmatter"; target: string }; + +/** Dispatch to the target-type-specific resolver. */ +export const resolveTarget = ( + model: DocumentModel, + instruction: Addressed +): ResolvedTarget | null => { + switch (instruction.targetType) { + case "heading": { + const resolved = resolveHeading(model, instruction.target); + if (resolved && instruction.within !== undefined) { + return resolveWithin(resolved.section, instruction.within); + } + return resolved; + } + case "block": + return resolveBlock(model, instruction.target); + case "frontmatter": + return resolveFrontmatter(model, instruction.target); + } +}; + +/** The target types the resolver understands (kept in sync with the union). */ +export const RESOLVABLE_TARGET_TYPES: readonly TargetType[] = [ + "heading", + "block", + "frontmatter", +]; diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..1515f44 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,389 @@ +/** + * The published Zod schema for a patch instruction. + * + * This is the single source of truth for the *shape and validity* of an + * instruction as a caller supplies it, from which downstream projects derive + * their own surfaces: obsidian-local-rest-api builds its MCP `vault_patch` tool + * input from {@link InstructionInputObjectSchema}'s field shape and its OpenAPI + * `PatchInstruction` component by running the same schema through + * `zod-to-json-schema`. Keeping it here means those three representations + * cannot drift. + * + * The schema is deliberately a *flat* object rather than a discriminated union: + * that is the only shape an MCP tool input accepts (a tool takes a flat + * `ZodRawShape`, never a top-level union), and it matches how both the REST and + * MCP layers already model an instruction. The cross-field rules that a + * discriminated union would encode in its structure — which carrier a cell + * requires, which `operation × scope` pairs are valid for a target — are instead + * enforced by {@link instructionAlgebra} in a `superRefine`. + * + * The hand-written {@link InstructionInput} union in `instructions.ts` remains + * the exported *type*; this schema is checked against it (see `schema.test.ts`) + * so the two stay in agreement. {@link patch} validates its input against + * {@link InstructionInputSchema} at the boundary, so a malformed instruction is + * rejected with a typed {@link InvalidInstructionError} before any handler runs. + */ + +import { z } from "zod"; + +import { + Operation, + Scope, + TargetType, + isValidCell, +} from "./instructions.js"; +import { DUPLICATE_DIGITS, DUPLICATE_MARKER } from "./constants.js"; + +// --- Field pieces -------------------------------------------------------- + +const operationValues = [ + "replace", + "prepend", + "append", + "delete", +] as const satisfies readonly Operation[]; + +const scopeValues = [ + "content", + "marker", + "markerAndContent", + "parent", +] as const satisfies readonly Scope[]; + +const targetTypeValues = [ + "heading", + "block", + "frontmatter", +] as const satisfies readonly TargetType[]; + +/** A heading containment path, or `null`/`[]` for the document root. */ +const headingAddress = z + .union([z.array(z.string()), z.null()]) + .describe( + "A heading's containment path: the ancestor heading texts from the top level down (e.g. [\"Overview\",\"Details\"]), or null/[] for the document root." + ); + +/** Where a moved section lands among its new parent's children. */ +const place = z + .union([ + z.enum(["first", "last"]), + z.object({ before: headingAddress }).strict(), + z.object({ after: headingAddress }).strict(), + ]) + .describe( + "Position among the new parent's children: \"first\", \"last\", or { before } / { after } a sibling heading address." + ); + +const destination = z + .object({ + parent: headingAddress.describe( + "The section's new parent heading address, or null/[] for the document root." + ), + place, + }) + .strict() + .describe( + "For a heading move (operation `replace`, scope `parent`): where the section is re-parented. Provide exactly one of `content`, `value`, or `destination`." + ); + +// --- The flat object ----------------------------------------------------- + +/** + * The instruction as a flat object, before cross-field validation. Exposed so + * consumers can read its `.shape` (e.g. to build an MCP tool input, one Zod + * field per parameter). Use {@link InstructionInputSchema} to actually + * validate an instruction — it adds the {@link instructionAlgebra} refinement. + */ +export const InstructionInputObjectSchema = z + .object({ + targetType: z + .enum(targetTypeValues) + .describe("The kind of node to edit."), + target: z + .union([z.array(z.string()), z.string(), z.null()]) + .describe( + "The node to edit. For a heading: an array of heading texts from the top level down to the target (e.g. [\"Overview\",\"Details\"]), or null/[] for the document root. If a heading is a duplicate of an earlier sibling under the same parent, its address carries an extra non-printable marker suffix appended by the server — copy that address verbatim from wherever the document's heading structure was discovered, never retype or reconstruct it. For a block: the bare block id, without the leading `^` (letters, numbers, hyphens, and underscores only) — a duplicate block id's later occurrence carries the same kind of marker suffix, copied verbatim the same way. For a frontmatter field: the key." + ), + within: z + .number() + .int() + .optional() + .describe( + "Refines a heading target to one of the section's direct-body top-level blocks (a paragraph, list, table, code fence, blockquote, …): 0 is the first block in document order, and a negative index counts from the end (-1 = last). Isolated `^id` lines are not counted, so indices match the rendered blocks. With scope `content`, the edit is a literal splice into that block — you own the joint, so `append` *continues* the block (e.g. content `\\n- item` extends a list) — and `delete` removes the block. With scope `markerAndContent`, `prepend`/`append` insert a new block immediately before/after it, with library-owned blank-line separators. Heading targets only; cannot be combined with `createTargetIfMissing`." + ), + operation: z + .enum(operationValues) + .describe( + "What happens to the scoped span: replace it, insert before (`prepend`) or after (`append`), or `delete` it." + ), + scope: z + .enum(scopeValues) + .default("content") + .describe( + "Which part of the target the operation acts on (default `content`). `content`: the node body — for a heading, its whole subtree below the heading line. `marker`: the label only — a heading line, a block `^id`, or a frontmatter key (`replace` renames it). `markerAndContent`: the whole node/subtree (`prepend`/`append` insert a sibling). `parent`: a heading's place in the tree — only with operation `replace`, carrying a `destination` (a move)." + ), + content: z + .string() + .describe( + "String payload: a heading/block body or label, a new block id for a block `marker` rename (letters, numbers, hyphens, and underscores only), or a new frontmatter key name for a frontmatter `marker` rename. Heading levels are relative to the edited span (a leading `#` becomes a direct child). For heading writes, whitespace is library-owned: leading/trailing blank lines here are ignored, and the engine supplies the blank line that keeps inserted content a separate block — never add newlines to control spacing. (Block-target `content` is the exception: it is spliced literally for inline edits.) A heading `marker` rename may not contain a line break. Provide exactly one of `content`, `value`, or `destination`." + ) + .optional(), + value: z + .unknown() + .describe( + "Structured JSON payload: a frontmatter value (any JSON — string, number, boolean, array, object, null; for `prepend`/`append` this merges: list concat, dict merge, string concat), or table rows on a `block` target's `content` cell (a 2-D array of strings, one row per entry — `replace` swaps the body rows, `prepend`/`append` insert before/after the existing ones; each row's length must match the table's column count). Table cells are content, not table source: a `|` is escaped for you, and a cell containing a line break is rejected, since a row is a single line. Provide exactly one of `content`, `value`, or `destination`." + ) + .optional(), + destination: destination.optional(), + ifMatch: z + .string() + .describe( + "Optimistic-concurrency token (the `version` from a prior document map). If set and the document has changed since, the patch fails with a precondition error without modifying the file." + ) + .optional(), + createTargetIfMissing: z + .boolean() + .default(false) + .describe( + "Create the target (heading path, block id, or frontmatter key) if it does not already exist." + ), + rejectIfContentPreexists: z + .boolean() + .default(false) + .describe( + "Fail a `prepend`/`append` when the string content already appears in the target span (makes those operations idempotent on retry)." + ), + }) + .describe( + "A single edit expressed as one operation applied to a scope of a target node. The payload rides in exactly one of `content`, `value`, or `destination`, chosen by what it is. Not every operation×scope×targetType combination is valid; invalid ones are rejected." + ); + +// --- The algebra (cross-field validity) ---------------------------------- + +/** Which payload carrier a valid cell requires (or `none` for a delete). */ +type Carrier = "content" | "value" | "destination" | "none"; + +/** + * The carrier(s) a `(targetType, operation, scope)` cell accepts, mirroring the + * {@link Instruction} union member(s) for that cell. Every cell expects exactly + * one carrier except `block`'s `content` cell, which accepts either `content` + * (literal text) or `value` (structured table rows) — the same way `frontmatter` + * already distinguishes `content` (key rename) from `value` (value write), just + * within one scope instead of across two. Assumes the cell is already known + * valid (see {@link isValidCell}). + */ +const expectedCarriers = ( + targetType: TargetType, + operation: Operation, + scope: Scope +): readonly Carrier[] => { + if (operation === "delete") return ["none"]; + if (scope === "parent") return ["destination"]; // heading move + if (targetType === "frontmatter") { + return scope === "marker" ? ["content"] : ["value"]; // rename vs. value write + } + if (targetType === "block" && scope === "content") { + return ["content", "value"]; // literal text, or structured table rows + } + return ["content"]; // heading/block label or whole-node write +}; + +const carriers = ["content", "value", "destination"] as const; + +/** + * A block id's allowed character set — mirrors `BLOCK_REFERENCE_REGEX` in + * `model.ts`, which is what actually recognizes a `^id` marker in the + * document. A target or rename outside this set could never address (or + * produce) a real block reference, so it is rejected here rather than + * spliced in verbatim and silently failing to parse as one. + */ +const BLOCK_ID_PATTERN = /^[A-Za-z0-9_-]+$/; + +/** + * A block *target* additionally accepts the disambiguated form a duplicate + * block id's later occurrence is addressed by (see disambiguatedBlockId in + * projection.ts): the raw id followed by the reserved marker and one or more + * digit codepoints. Only `target` (addressing an existing block) accepts + * this — `content` on a `marker`-scope rename (naming a *new* real block id) + * still requires {@link BLOCK_ID_PATTERN} alone, since a disambiguated + * address is never something you rename a block to. + */ +const BLOCK_TARGET_PATTERN = new RegExp( + `^[A-Za-z0-9_-]+(${DUPLICATE_MARKER}[${DUPLICATE_DIGITS.join("")}]+)?$`, + "u" +); + +/** A 2-D array of strings: table rows for a `block` `content`-cell `value` write. */ +const isTableRowValue = (value: unknown): value is string[][] => + Array.isArray(value) && + value.every( + (row) => Array.isArray(row) && row.every((cell) => typeof cell === "string") + ); + +/** + * Validate the cross-field rules the flat object cannot express on its own: the + * target shape must match its type, the `operation × scope` cell must be part of + * the algebra, and exactly one of the carrier(s) the cell expects must be + * present — with the right shape, for cells whose carrier is structured. + */ +const instructionAlgebra = ( + input: z.infer, + ctx: z.RefinementCtx +): void => { + const { targetType, operation, scope, target } = input; + + // Target shape by type. + if (targetType === "heading") { + if (target !== null && !Array.isArray(target)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["target"], + message: + "a heading target must be an array of heading texts, or null for the document root", + }); + } + } else if (typeof target !== "string") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["target"], + message: `a ${targetType} target must be a string`, + }); + } else if (targetType === "block" && !BLOCK_TARGET_PATTERN.test(target)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["target"], + message: + "a block id may contain only letters, numbers, hyphens, and underscores", + }); + } + + // `within` sub-matrix: a positional body-block refinement narrows the + // heading cells to the ones meaningful for a single block — every + // `content`-scope operation (literal splice / delete of the block itself) + // plus `prepend`/`append @ markerAndContent` (sibling block insert). + if (input.within !== undefined) { + if (targetType !== "heading") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["within"], + message: "`within` applies only to a heading target", + }); + } else { + const withinValid = + scope === "content" || + (scope === "markerAndContent" && + (operation === "prepend" || operation === "append")); + if (!withinValid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["operation"], + message: `${operation} @ ${scope} is not a valid operation for a within-refined heading target`, + }); + } + } + if (input.createTargetIfMissing) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["createTargetIfMissing"], + message: + "`createTargetIfMissing` cannot be combined with `within`: a positional block cannot be created by its index", + }); + } + } + + // Cell validity. + if (!isValidCell(targetType, operation, scope)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["operation"], + message: `${operation} @ ${scope} is not a valid operation for a ${targetType} target`, + }); + return; // carrier expectations are undefined for an invalid cell + } + + // Carrier: exactly one of the ones the cell expects, and no others. + const expected = expectedCarriers(targetType, operation, scope); + const present = carriers.filter((carrier) => input[carrier] !== undefined); + + if (expected[0] === "none") { + for (const carrier of present) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [carrier], + message: `a ${operation} carries no payload; remove \`${carrier}\``, + }); + } + return; + } + + if (present.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [expected[0]], + message: `${operation} @ ${scope} on a ${targetType} target requires ${ + expected.length === 1 ? `\`${expected[0]}\`` : expected.map((c) => `\`${c}\``).join(" or ") + }`, + }); + } else if (present.length > 1) { + for (const carrier of present) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [carrier], + message: `${operation} @ ${scope} on a ${targetType} target carries its payload in exactly one of ${expected + .map((c) => `\`${c}\``) + .join(" or ")}, not both`, + }); + } + } else if (!expected.includes(present[0])) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [present[0]], + message: `${operation} @ ${scope} on a ${targetType} target carries its payload in ${ + expected.length === 1 ? `\`${expected[0]}\`` : expected.map((c) => `\`${c}\``).join(" or ") + }, not \`${present[0]}\``, + }); + } else if ( + targetType === "block" && + scope === "content" && + present[0] === "value" && + !isTableRowValue(input.value) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["value"], + message: + "value for a block content write must be a 2-D array of strings — one row per entry, one cell per column", + }); + } else if ( + targetType === "block" && + scope === "marker" && + operation === "replace" && + !BLOCK_ID_PATTERN.test(input.content as string) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["content"], + message: + "a block id may contain only letters, numbers, hyphens, and underscores", + }); + } else if ( + targetType === "heading" && + scope === "marker" && + /[\r\n]/.test(input.content as string) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["content"], + message: "a heading marker rename cannot contain a line break", + }); + } +}; + +/** + * The full instruction schema: the flat object plus the {@link instructionAlgebra} + * cross-field refinement. {@link patch} parses input through this at the + * boundary. It is a `ZodEffects`, so read `.shape` from + * {@link InstructionInputObjectSchema} rather than from this. + */ +export const InstructionInputSchema = + InstructionInputObjectSchema.superRefine(instructionAlgebra); diff --git a/src/splice.ts b/src/splice.ts new file mode 100644 index 0000000..774966f --- /dev/null +++ b/src/splice.ts @@ -0,0 +1,58 @@ +/** + * The one primitive that mutates a document: apply a set of non-overlapping + * range replacements in a single pass. Because the model owns whitespace via + * `trailingGap`, every edit here is an exact byte-range replacement — there are + * no newline heuristics. Callers decide the ranges and text; this just stitches + * the untouched spans back together, so regions outside an edit are unchanged. + */ + +import { DocumentRange } from "./types.js"; + +export interface Edit { + range: DocumentRange; + text: string; +} + +export class OverlappingEditsError extends Error {} + +/** + * Apply `edits` to `document`. Edits may be given in any order and may be + * insertions (`start === end`); they must not overlap. Returns the new + * document. + */ +export const applyEdits = (document: string, edits: Edit[]): string => { + // Sort by start, breaking ties so a zero-length insertion precedes a + // same-start replacement/deletion (its text lands just before the region). + // This lets a move express "insert here" and "delete the old span" as two + // edits that share a boundary without spuriously overlapping. + const sorted = [...edits].sort( + (a, b) => + a.range.start - b.range.start || + (a.range.end - a.range.start) - (b.range.end - b.range.start) + ); + const parts: string[] = []; + let cursor = 0; + for (const { range, text } of sorted) { + if (range.end < range.start) { + throw new OverlappingEditsError( + `inverted range [${range.start}, ${range.end})` + ); + } + if (range.start < cursor) { + throw new OverlappingEditsError( + `edit at [${range.start}, ${range.end}) overlaps a preceding edit ending at ${cursor}` + ); + } + parts.push(document.slice(cursor, range.start), text); + cursor = range.end; + } + parts.push(document.slice(cursor)); + return parts.join(""); +}; + +/** Replace a single range's text. */ +export const spliceRange = ( + document: string, + range: DocumentRange, + text: string +): string => applyEdits(document, [{ range, text }]); diff --git a/src/tests/blocks.test.ts b/src/tests/blocks.test.ts new file mode 100644 index 0000000..cb681af --- /dev/null +++ b/src/tests/blocks.test.ts @@ -0,0 +1,74 @@ +import { buildModel, eachSection, BlockNode } from "../model"; + +const findById = (model: ReturnType, id: string): BlockNode | undefined => { + let found: BlockNode | undefined; + eachSection(model.root, (n) => + n.blocks.forEach((b) => { + if (b.id === id) found = b; + }) + ); + return found; +}; + +describe("findBlocks anchoring on duplicate blocks", () => { + test("an isolated ^id binds to the nearer of two textually-identical preceding blocks", () => { + // Two identical paragraphs, then an isolated `^ref` line: Obsidian's rule + // is that it targets the immediately preceding block (the second "foo"), + // not whichever occurrence `indexOf` happens to re-match first. + const doc = "foo\n\nfoo\n\n^ref\n"; + const model = buildModel(doc); + const firstFoo = doc.indexOf("foo"); + const secondFoo = doc.indexOf("foo", firstFoo + 1); + + const block = findById(model, "ref"); + expect(block).toBeDefined(); + expect(block!.isolated).toBe(true); + expect(block!.content.start).toBe(secondFoo); + expect(doc.slice(block!.content.start, block!.content.end)).toBe("foo"); + }); + + test("three identical paragraphs each anchor to their own occurrence", () => { + const doc = "foo\n\nfoo\n\nfoo ^a\n"; + const model = buildModel(doc); + const thirdFoo = doc.lastIndexOf("foo"); + + const block = findById(model, "a"); + expect(block).toBeDefined(); + expect(block!.isolated).toBe(false); + expect(block!.content.start).toBe(thirdFoo); + }); + + test("an isolated ^id binds to the nearest of three byte-identical, unseparated code blocks", () => { + // Fenced code blocks need no blank-line separator between them, and marked + // emits them as leaf tokens (no nested child token to nudge the shared + // search cursor forward either) -- so three back-to-back identical fences + // are the case that actually exposes `indexOf(raw, searchFrom)` re-matching + // the *first* occurrence for every later one, since nothing ever advances + // the cursor past it. + const fence = "```\nfoo\n```\n"; + const doc = fence + fence + "```\nfoo\n```" + "\n\n^ref\n"; + const model = buildModel(doc); + const thirdFenceStart = doc.lastIndexOf("```\nfoo\n```"); + + const block = findById(model, "ref"); + expect(block).toBeDefined(); + expect(block!.isolated).toBe(true); + expect(block!.content.start).toBe(thirdFenceStart); + expect(doc.slice(block!.content.start, block!.content.end)).toBe( + "```\nfoo\n```" + ); + }); + + test("two duplicate blocks each carrying their own inline id resolve to distinct spans", () => { + const doc = "same text ^one\n\nsame text ^two\n"; + const model = buildModel(doc); + + const one = findById(model, "one"); + const two = findById(model, "two"); + expect(one).toBeDefined(); + expect(two).toBeDefined(); + expect(one!.content.start).toBeLessThan(two!.content.start); + expect(doc.slice(one!.content.start, one!.content.end)).toBe("same text"); + expect(doc.slice(two!.content.start, two!.content.end)).toBe("same text"); + }); +}); diff --git a/src/tests/bodyChildren.test.ts b/src/tests/bodyChildren.test.ts new file mode 100644 index 0000000..41ba239 --- /dev/null +++ b/src/tests/bodyChildren.test.ts @@ -0,0 +1,128 @@ +import { buildModel, SectionNode } from "../model"; + +const sectionNamed = ( + model: ReturnType, + text: string +): SectionNode => { + const found = model.root.children.find((c) => c.heading?.text === text); + if (!found) throw new Error(`no section named ${text}`); + return found; +}; + +const childTexts = (doc: string, node: SectionNode): string[] => + node.bodyChildren.map((c) => doc.slice(c.range.start, c.range.end)); + +describe("findBodyChildren", () => { + test("a mixed body yields one child per top-level block, in order", () => { + const doc = [ + "# Mixed", + "", + "First paragraph.", + "", + "- one", + "- two", + "", + "```", + "fenced", + "```", + "", + "| a |", + "| - |", + "| 1 |", + "", + "> quoted", + "", + "---", + "", + "tail paragraph", + "", + ].join("\n"); + const model = buildModel(doc); + const section = sectionNamed(model, "Mixed"); + expect(section.bodyChildren.map((c) => c.kind)).toEqual([ + "paragraph", + "list", + "code", + "table", + "blockquote", + "hr", + "paragraph", + ]); + expect(childTexts(doc, section)).toEqual([ + "First paragraph.", + "- one\n- two", + "```\nfenced\n```", + "| a |\n| - |\n| 1 |", + "> quoted", + "---", + "tail paragraph", + ]); + }); + + test("an empty section body has no children", () => { + const model = buildModel("# Empty\n\n# Next\n\nbody\n"); + expect(sectionNamed(model, "Empty").bodyChildren).toEqual([]); + expect(sectionNamed(model, "Next").bodyChildren).toHaveLength(1); + }); + + test("preamble blocks before the first heading belong to the root", () => { + const doc = "Preamble one.\n\nPreamble two.\n\n# First\n\nbody\n"; + const model = buildModel(doc); + expect(childTexts(doc, model.root)).toEqual([ + "Preamble one.", + "Preamble two.", + ]); + }); + + test("children split between a section's direct body and its subsection", () => { + const doc = "# A\n\npara a\n\n## B\n\nsub para\n"; + const model = buildModel(doc); + const a = sectionNamed(model, "A"); + expect(childTexts(doc, a)).toEqual(["para a"]); + expect(childTexts(doc, a.children[0])).toEqual(["sub para"]); + }); + + test("an isolated ^id marker line is not a child; an inline ^id stays in its block's span", () => { + const doc = "# H\n\nfirst para ^inline\n\n- item\n\n^listref\n\nlast\n"; + const model = buildModel(doc); + const section = sectionNamed(model, "H"); + // The isolated `^listref` line annotates the list; it is not counted, so + // indices here match the rendered blocks a reader sees. + expect(childTexts(doc, section)).toEqual([ + "first para ^inline", + "- item", + "last", + ]); + }); + + test("a setext heading is structure, not a body child", () => { + const doc = "Title\n=====\n\nbody para\n"; + const model = buildModel(doc); + expect(model.root.bodyChildren).toEqual([]); + const title = model.root.children[0]; + expect(title.heading?.text).toEqual("Title"); + expect(childTexts(doc, title)).toEqual(["body para"]); + }); + + test("CRLF documents index children at original byte offsets", () => { + const doc = "# H\r\n\r\nfirst\r\n\r\n- a\r\n- b\r\n"; + const model = buildModel(doc); + const section = sectionNamed(model, "H"); + expect(childTexts(doc, section)).toEqual(["first", "- a\r\n- b"]); + }); + + test("a final block with no trailing newline is still a child", () => { + const doc = "# H\n\nfirst\n\nlast without newline"; + const model = buildModel(doc); + expect(childTexts(doc, sectionNamed(model, "H"))).toEqual([ + "first", + "last without newline", + ]); + }); + + test("frontmatter is not a body child", () => { + const doc = "---\ntitle: t\n---\n\nonly para\n"; + const model = buildModel(doc); + expect(childTexts(doc, model.root)).toEqual(["only para"]); + }); +}); diff --git a/src/tests/conformance.test.ts b/src/tests/conformance.test.ts new file mode 100644 index 0000000..b4393aa --- /dev/null +++ b/src/tests/conformance.test.ts @@ -0,0 +1,138 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { buildModel, eachSection, SectionNode } from "../model"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const CONFORMANCE_DIR = path.join(__dirname, "conformance"); + +interface Golden { + headings: Array<{ level: number; text: string; start: number; end: number }>; + sections: Array<{ type: string; start: number; end: number }>; + blocks: Record; + listItems: Array<{ start: number; end: number }>; +} + +interface Fixture { + name: string; + text: string; + golden: Golden | null; +} + +const loadFixtures = (): Fixture[] => + fs + .readdirSync(CONFORMANCE_DIR) + .filter((f) => f.endsWith(".md") && f !== "README.md") + .map((f) => { + const goldenPath = path.join( + CONFORMANCE_DIR, + f.replace(/\.md$/, ".obsidian.json") + ); + return { + name: f, + text: fs.readFileSync(path.join(CONFORMANCE_DIR, f), "utf-8"), + golden: fs.existsSync(goldenPath) + ? (JSON.parse(fs.readFileSync(goldenPath, "utf-8")) as Golden) + : null, + }; + }); + +const sectionsOf = (text: string): SectionNode[] => { + const model = buildModel(text); + const nodes: SectionNode[] = []; + eachSection(model.root, (n) => { + if (n.heading) { + nodes.push(n); + } + }); + return nodes; +}; + +const blockIds = (text: string): Set => { + const model = buildModel(text); + const ids = new Set(); + eachSection(model.root, (n) => n.blocks.forEach((b) => ids.add(b.id))); + return ids; +}; + +const fixtures = loadFixtures(); +const withGolden = fixtures.filter((f) => f.golden !== null); +const withoutGolden = fixtures.filter((f) => f.golden === null); + +describe("Obsidian conformance", () => { + if (withGolden.length === 0) { + // No goldens captured yet: keep the suite green but make the gap visible. + test.todo( + "capture goldens with src/tests/conformance/capture-snippet.js (see conformance/README.md)" + ); + } + + const checkFixture = ({ text, golden }: Fixture): void => { + const g = golden as Golden; + const sections = sectionsOf(text); + + test("model heading levels, texts and start offsets match Obsidian", () => { + const modelHeadings = sections.map((s) => ({ + level: s.heading!.level, + text: s.heading!.text, + start: s.marker!.start, + })); + const obsidianHeadings = g.headings.map((h) => ({ + level: h.level, + text: h.text, + start: h.start, + })); + expect(modelHeadings).toEqual(obsidianHeadings); + }); + + test("model block ids match Obsidian's block ids", () => { + expect([...blockIds(text)].sort()).toEqual(Object.keys(g.blocks).sort()); + }); + + test("body children match Obsidian's section spans", () => { + // Obsidian's section cache lists every rendered top-level block in + // document order, omitting isolated `^id` marker lines; filtering out + // heading and yaml entries leaves exactly the spans `bodyChildren` + // should produce (pre-order section traversal = document order). + const model = buildModel(text); + const modelSpans: Array<{ start: number; end: number }> = []; + eachSection(model.root, (node) => { + for (const child of node.bodyChildren) { + modelSpans.push({ start: child.range.start, end: child.range.end }); + } + }); + const obsidianSpans = g.sections + .filter((s) => s.type !== "heading" && s.type !== "yaml") + .map((s) => ({ start: s.start, end: s.end })); + expect(modelSpans).toEqual(obsidianSpans); + }); + + test("each model block span equals Obsidian's block span", () => { + const model = buildModel(text); + eachSection(model.root, (node) => { + for (const block of node.blocks) { + const span = g.blocks[block.id]; + expect(span).toBeDefined(); + // Obsidian reports a single span per block: for an isolated `^id` it + // is the preceding block (our `content`); for an inline `^id` it runs + // from the content start through the marker. + const modelSpan = block.isolated + ? { start: block.content.start, end: block.content.end } + : { start: block.content.start, end: block.marker.end }; + expect(modelSpan).toEqual({ start: span.start, end: span.end }); + } + }); + }); + }; + + for (const fixture of withGolden) { + describe(fixture.name, () => checkFixture(fixture)); + } + + for (const fixture of withoutGolden) { + test.todo(`capture golden for ${fixture.name}`); + } +}); diff --git a/src/tests/conformance/README.md b/src/tests/conformance/README.md new file mode 100644 index 0000000..2b6b7e0 --- /dev/null +++ b/src/tests/conformance/README.md @@ -0,0 +1,54 @@ +# Obsidian conformance fixtures + +These `*.md` fixtures cross-validate markdown-patch's internal model against +Obsidian's own parse (`metadataCache`). Obsidian is the source of truth for what +counts as a heading, section, or block, so the model must agree with it on +boundaries. + +## How it works + +- Each `*.md` fixture has a frozen golden `*.obsidian.json` beside it, captured + from live Obsidian (see below). +- `conformance.test.ts` builds the model for each fixture and asserts that the + heading levels/texts/offsets, the block id set, and each block's span match + the golden exactly. Block spans encode Obsidian's rules directly: offsets + index raw bytes (a CRLF counts as two), spans exclude trailing newlines, and + an isolated `^id` on its own line takes the span of the block above it. +- Fixtures **without** a golden are reported as pending (the suite stays green), + so the capture step can lag behind adding a fixture. + +## Capturing / refreshing goldens + +The goldens are only regenerated when fixtures change — this needs a live +Obsidian instance and is a manual, occasional step. Either method below +produces the same golden shape. + +### Method A — DevTools console snippet (no plugin changes) + +1. Copy this folder's `*.md` files into a `conformance/` folder at the root of + any Obsidian vault. +2. Open that vault, open the developer console (Ctrl/Cmd-Shift-I), and wait a + moment for the metadata cache to settle. +3. Paste the contents of [`capture-snippet.js`](./capture-snippet.js) into the + console and run it. It writes `.obsidian.json` beside each fixture in + the vault. +4. Copy the resulting `*.obsidian.json` files back into this folder. + +### Method B — temporary REST route (fully scriptable, no console) + +With a live Obsidian running the sibling `obsidian-local-rest-api` plugin from +source (it rebuilds on save), a coding agent can refresh goldens end to end: + +1. Add a throwaway `GET /__debug_cache/*` route to that plugin's + `src/requestHandler.ts` that resolves the path with `getAbstractFileByPath` + and returns `{ content, cache: metadataCache.getFileCache(file) }` as JSON. +2. Wait a few seconds for the rebuild, then for each fixture `PUT /vault/...` + its exact bytes, poll the `note+json` endpoint until indexed, and + `GET /__debug_cache/...`. +3. Reduce each raw cache to the golden shape (`headings`/`sections`/`blocks`/ + `listItems` with `position.*.offset` start/end) and write it here. +4. **Delete the temporary route and the fixtures you PUT into the vault; do not + commit the route.** + +Both methods are throwaway capture tooling: neither is part of the shipped +library. diff --git a/src/tests/conformance/block-kinds.md b/src/tests/conformance/block-kinds.md new file mode 100644 index 0000000..3bd7900 --- /dev/null +++ b/src/tests/conformance/block-kinds.md @@ -0,0 +1,19 @@ +# Blocks + +A plain paragraph with a trailing block id. ^para1 + +- List item one +- List item two + +^list1 + +| Col A | Col B | +| ----- | ----- | +| 1 | 2 | + +^table1 + +> A blockquote line one. +> A blockquote line two. + +^quote1 diff --git a/src/tests/conformance/block-kinds.obsidian.json b/src/tests/conformance/block-kinds.obsidian.json new file mode 100644 index 0000000..c87c698 --- /dev/null +++ b/src/tests/conformance/block-kinds.obsidian.json @@ -0,0 +1,65 @@ +{ + "headings": [ + { + "level": 1, + "text": "Blocks", + "start": 0, + "end": 8 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 8 + }, + { + "type": "paragraph", + "start": 10, + "end": 60 + }, + { + "type": "list", + "start": 62, + "end": 93 + }, + { + "type": "table", + "start": 103, + "end": 156 + }, + { + "type": "blockquote", + "start": 167, + "end": 216 + } + ], + "blocks": { + "para1": { + "start": 10, + "end": 60 + }, + "list1": { + "start": 62, + "end": 93 + }, + "table1": { + "start": 103, + "end": 156 + }, + "quote1": { + "start": 167, + "end": 216 + } + }, + "listItems": [ + { + "start": 62, + "end": 77 + }, + { + "start": 78, + "end": 93 + } + ] +} diff --git a/src/tests/conformance/body-children.md b/src/tests/conformance/body-children.md new file mode 100644 index 0000000..d0afe53 --- /dev/null +++ b/src/tests/conformance/body-children.md @@ -0,0 +1,24 @@ +# Kinds + +First paragraph. + +Second paragraph with id. ^inline1 + +- one +- two + +^listref + +--- + +```text +fenced +``` + +> quoted line + +# Empty + +# After + +last paragraph, no trailing newline diff --git a/src/tests/conformance/body-children.obsidian.json b/src/tests/conformance/body-children.obsidian.json new file mode 100644 index 0000000..ef0b5bc --- /dev/null +++ b/src/tests/conformance/body-children.obsidian.json @@ -0,0 +1,94 @@ +{ + "headings": [ + { + "level": 1, + "text": "Kinds", + "start": 0, + "end": 7 + }, + { + "level": 1, + "text": "Empty", + "start": 126, + "end": 133 + }, + { + "level": 1, + "text": "After", + "start": 135, + "end": 142 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 7 + }, + { + "type": "paragraph", + "start": 9, + "end": 25 + }, + { + "type": "paragraph", + "start": 27, + "end": 61 + }, + { + "type": "list", + "start": 63, + "end": 74 + }, + { + "type": "thematicBreak", + "start": 86, + "end": 89 + }, + { + "type": "code", + "start": 91, + "end": 109 + }, + { + "type": "blockquote", + "start": 111, + "end": 124 + }, + { + "type": "heading", + "start": 126, + "end": 133 + }, + { + "type": "heading", + "start": 135, + "end": 142 + }, + { + "type": "paragraph", + "start": 144, + "end": 179 + } + ], + "blocks": { + "inline1": { + "start": 27, + "end": 61 + }, + "listref": { + "start": 63, + "end": 74 + } + }, + "listItems": [ + { + "start": 63, + "end": 68 + }, + { + "start": 69, + "end": 74 + } + ] +} diff --git a/src/tests/conformance/capture-snippet.js b/src/tests/conformance/capture-snippet.js new file mode 100644 index 0000000..1e72aa6 --- /dev/null +++ b/src/tests/conformance/capture-snippet.js @@ -0,0 +1,70 @@ +/* + * Obsidian conformance-capture snippet. + * + * Freezes Obsidian's canonical parse (metadataCache) for the conformance + * fixtures so markdown-patch's model can be cross-validated against it. This is + * throwaway capture tooling — it is not shipped and touches no plugin API. + * + * Usage (run once, re-run when fixtures change): + * 1. Copy every `*.md` file from this folder into a folder named + * `conformance/` at the root of any Obsidian vault. + * 2. Open that vault in Obsidian, then open the developer console + * (Ctrl/Cmd-Shift-I) and let the metadata cache settle for a moment. + * 3. Paste this entire file into the console and press Enter. + * 4. It writes a `.obsidian.json` beside each fixture in the vault's + * `conformance/` folder. Copy those JSON files back into this folder, + * next to the matching `.md` fixture. + * + * The golden shape is intentionally minimal — only offsets markdown-patch's + * model is validated against: + * { headings: [{ level, text, start, end }], + * sections: [{ type, start, end }], + * blocks: { id: { start, end } }, + * listItems:[{ start, end }] } + * where start/end are `position.start.offset` / `position.end.offset`. + */ +(async () => { + const FOLDER = "conformance"; + const offsets = (pos) => ({ start: pos.start.offset, end: pos.end.offset }); + + const files = app.vault + .getMarkdownFiles() + .filter((f) => f.path.startsWith(`${FOLDER}/`)); + + if (files.length === 0) { + console.warn( + `No fixtures found under "${FOLDER}/". Copy the conformance *.md files there first.` + ); + return; + } + + for (const file of files) { + const cache = app.metadataCache.getFileCache(file); + if (!cache) { + console.warn(`No cache yet for ${file.path}; skipping.`); + continue; + } + const golden = { + headings: (cache.headings ?? []).map((h) => ({ + level: h.level, + text: h.heading, + ...offsets(h.position), + })), + sections: (cache.sections ?? []).map((s) => ({ + type: s.type, + ...offsets(s.position), + })), + blocks: Object.fromEntries( + Object.entries(cache.blocks ?? {}).map(([id, b]) => [ + id, + offsets(b.position), + ]) + ), + listItems: (cache.listItems ?? []).map((li) => offsets(li.position)), + }; + const outPath = file.path.replace(/\.md$/, ".obsidian.json"); + await app.vault.adapter.write(outPath, JSON.stringify(golden, null, 2) + "\n"); + console.log(`captured ${outPath}`); + } + console.log(`Done. Captured ${files.length} fixture(s).`); +})(); diff --git a/src/tests/conformance/code-fence-heading.md b/src/tests/conformance/code-fence-heading.md new file mode 100644 index 0000000..17fe64a --- /dev/null +++ b/src/tests/conformance/code-fence-heading.md @@ -0,0 +1,14 @@ +# Guide + +Some intro text. + +```markdown +# Not a real heading +## Also not a heading +``` + +More text after the fence. + +## Real Subsection + +Body of the real subsection. diff --git a/src/tests/conformance/code-fence-heading.obsidian.json b/src/tests/conformance/code-fence-heading.obsidian.json new file mode 100644 index 0000000..ce4060c --- /dev/null +++ b/src/tests/conformance/code-fence-heading.obsidian.json @@ -0,0 +1,50 @@ +{ + "headings": [ + { + "level": 1, + "text": "Guide", + "start": 0, + "end": 7 + }, + { + "level": 2, + "text": "Real Subsection", + "start": 115, + "end": 133 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 7 + }, + { + "type": "paragraph", + "start": 9, + "end": 25 + }, + { + "type": "code", + "start": 27, + "end": 85 + }, + { + "type": "paragraph", + "start": 87, + "end": 113 + }, + { + "type": "heading", + "start": 115, + "end": 133 + }, + { + "type": "paragraph", + "start": 135, + "end": 163 + } + ], + "blocks": {}, + "listItems": [] +} diff --git a/src/tests/conformance/crlf.md b/src/tests/conformance/crlf.md new file mode 100644 index 0000000..127337e --- /dev/null +++ b/src/tests/conformance/crlf.md @@ -0,0 +1,7 @@ +# Title + +Paragraph one under title. + +## Sub + +Paragraph under sub. ^b1 diff --git a/src/tests/conformance/crlf.obsidian.json b/src/tests/conformance/crlf.obsidian.json new file mode 100644 index 0000000..50dcb1e --- /dev/null +++ b/src/tests/conformance/crlf.obsidian.json @@ -0,0 +1,45 @@ +{ + "headings": [ + { + "level": 1, + "text": "Title", + "start": 0, + "end": 7 + }, + { + "level": 2, + "text": "Sub", + "start": 41, + "end": 47 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 7 + }, + { + "type": "paragraph", + "start": 11, + "end": 37 + }, + { + "type": "heading", + "start": 41, + "end": 47 + }, + { + "type": "paragraph", + "start": 51, + "end": 75 + } + ], + "blocks": { + "b1": { + "start": 51, + "end": 75 + } + }, + "listItems": [] +} diff --git a/src/tests/conformance/duplicate-headings.md b/src/tests/conformance/duplicate-headings.md new file mode 100644 index 0000000..9048808 --- /dev/null +++ b/src/tests/conformance/duplicate-headings.md @@ -0,0 +1,13 @@ +# Log + +## 2026-07-18 + +First entry body. + +## 2026-07-18 + +Second entry with the same heading text. + +## 2026-07-19 + +Third entry. diff --git a/src/tests/conformance/duplicate-headings.obsidian.json b/src/tests/conformance/duplicate-headings.obsidian.json new file mode 100644 index 0000000..ea2477b --- /dev/null +++ b/src/tests/conformance/duplicate-headings.obsidian.json @@ -0,0 +1,67 @@ +{ + "headings": [ + { + "level": 1, + "text": "Log", + "start": 0, + "end": 5 + }, + { + "level": 2, + "text": "2026-07-18", + "start": 7, + "end": 20 + }, + { + "level": 2, + "text": "2026-07-18", + "start": 41, + "end": 54 + }, + { + "level": 2, + "text": "2026-07-19", + "start": 98, + "end": 111 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 5 + }, + { + "type": "heading", + "start": 7, + "end": 20 + }, + { + "type": "paragraph", + "start": 22, + "end": 39 + }, + { + "type": "heading", + "start": 41, + "end": 54 + }, + { + "type": "paragraph", + "start": 56, + "end": 96 + }, + { + "type": "heading", + "start": 98, + "end": 111 + }, + { + "type": "paragraph", + "start": 113, + "end": 125 + } + ], + "blocks": {}, + "listItems": [] +} diff --git a/src/tests/conformance/empty-heading.md b/src/tests/conformance/empty-heading.md new file mode 100644 index 0000000..03cb8f7 --- /dev/null +++ b/src/tests/conformance/empty-heading.md @@ -0,0 +1,11 @@ +# + +Body under an empty-text heading. + +## Named + +Body under a named subsection. + +## + +Body under an empty-text subsection. diff --git a/src/tests/conformance/empty-heading.obsidian.json b/src/tests/conformance/empty-heading.obsidian.json new file mode 100644 index 0000000..ab3b730 --- /dev/null +++ b/src/tests/conformance/empty-heading.obsidian.json @@ -0,0 +1,56 @@ +{ + "headings": [ + { + "level": 1, + "text": "", + "start": 0, + "end": 2 + }, + { + "level": 2, + "text": "Named", + "start": 39, + "end": 47 + }, + { + "level": 2, + "text": "", + "start": 81, + "end": 83 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 2 + }, + { + "type": "paragraph", + "start": 4, + "end": 37 + }, + { + "type": "heading", + "start": 39, + "end": 47 + }, + { + "type": "paragraph", + "start": 49, + "end": 79 + }, + { + "type": "heading", + "start": 81, + "end": 83 + }, + { + "type": "paragraph", + "start": 85, + "end": 121 + } + ], + "blocks": {}, + "listItems": [] +} diff --git a/src/tests/conformance/nested-lists.md b/src/tests/conformance/nested-lists.md new file mode 100644 index 0000000..79d8620 --- /dev/null +++ b/src/tests/conformance/nested-lists.md @@ -0,0 +1,14 @@ +# Tasks + +- Item one +- Item two + + Loose paragraph inside item two. + +- Item three + - Nested a + - Nested b + +## Done + +- Finished item diff --git a/src/tests/conformance/nested-lists.obsidian.json b/src/tests/conformance/nested-lists.obsidian.json new file mode 100644 index 0000000..86eae52 --- /dev/null +++ b/src/tests/conformance/nested-lists.obsidian.json @@ -0,0 +1,65 @@ +{ + "headings": [ + { + "level": 1, + "text": "Tasks", + "start": 0, + "end": 7 + }, + { + "level": 2, + "text": "Done", + "start": 106, + "end": 113 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 7 + }, + { + "type": "list", + "start": 9, + "end": 104 + }, + { + "type": "heading", + "start": 106, + "end": 113 + }, + { + "type": "list", + "start": 115, + "end": 130 + } + ], + "blocks": {}, + "listItems": [ + { + "start": 9, + "end": 19 + }, + { + "start": 20, + "end": 66 + }, + { + "start": 68, + "end": 80 + }, + { + "start": 82, + "end": 92 + }, + { + "start": 94, + "end": 104 + }, + { + "start": 115, + "end": 130 + } + ] +} diff --git a/src/tests/conformance/skipped-levels.md b/src/tests/conformance/skipped-levels.md new file mode 100644 index 0000000..5827910 --- /dev/null +++ b/src/tests/conformance/skipped-levels.md @@ -0,0 +1,11 @@ +# Top + +Intro under top. + +### Deep child + +This h3 sits directly under an h1, skipping h2. + +# Second Top + +Body of second top. diff --git a/src/tests/conformance/skipped-levels.obsidian.json b/src/tests/conformance/skipped-levels.obsidian.json new file mode 100644 index 0000000..66b9a9a --- /dev/null +++ b/src/tests/conformance/skipped-levels.obsidian.json @@ -0,0 +1,56 @@ +{ + "headings": [ + { + "level": 1, + "text": "Top", + "start": 0, + "end": 5 + }, + { + "level": 3, + "text": "Deep child", + "start": 25, + "end": 39 + }, + { + "level": 1, + "text": "Second Top", + "start": 90, + "end": 102 + } + ], + "sections": [ + { + "type": "heading", + "start": 0, + "end": 5 + }, + { + "type": "paragraph", + "start": 7, + "end": 23 + }, + { + "type": "heading", + "start": 25, + "end": 39 + }, + { + "type": "paragraph", + "start": 41, + "end": 88 + }, + { + "type": "heading", + "start": 90, + "end": 102 + }, + { + "type": "paragraph", + "start": 104, + "end": 123 + } + ], + "blocks": {}, + "listItems": [] +} diff --git a/src/tests/conformance/table-eof.md b/src/tests/conformance/table-eof.md new file mode 100644 index 0000000..3efb2f0 --- /dev/null +++ b/src/tests/conformance/table-eof.md @@ -0,0 +1,10 @@ +--- +title: table at eof +--- + +# Journal + +| Date | Event | +| ---------- | ------------- | +| 2026-01-01 | Initial entry | +| 2026-01-02 | Second entry | diff --git a/src/tests/conformance/table-eof.obsidian.json b/src/tests/conformance/table-eof.obsidian.json new file mode 100644 index 0000000..9926c4e --- /dev/null +++ b/src/tests/conformance/table-eof.obsidian.json @@ -0,0 +1,29 @@ +{ + "headings": [ + { + "level": 1, + "text": "Journal", + "start": 29, + "end": 38 + } + ], + "sections": [ + { + "type": "yaml", + "start": 0, + "end": 27 + }, + { + "type": "heading", + "start": 29, + "end": 38 + }, + { + "type": "table", + "start": 40, + "end": 163 + } + ], + "blocks": {}, + "listItems": [] +} diff --git a/src/tests/create.test.ts b/src/tests/create.test.ts new file mode 100644 index 0000000..364e445 --- /dev/null +++ b/src/tests/create.test.ts @@ -0,0 +1,162 @@ +import { patch } from "../engine"; +import { TargetNotFoundError } from "../instructions"; + +describe("createTargetIfMissing — headings", () => { + test("creates a missing child under an existing ancestor and places content", () => { + const result = patch("# A\na-body\n", { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "content", + content: "hi", + createTargetIfMissing: true, + }); + expect(result.document).toBe("# A\na-body\n## B\nhi\n"); + }); + + test("creates a whole missing chain with consecutive levels (no skipped-depth hole)", () => { + const result = patch("# A\na\n", { + targetType: "heading", + target: ["A", "B", "C"], + operation: "replace", + scope: "content", + content: "x", + createTargetIfMissing: true, + }); + expect(result.document).toBe("# A\na\n## B\n### C\nx\n"); + }); + + test("creates a top-level heading when no ancestor matches", () => { + const result = patch("# A\na\n", { + targetType: "heading", + target: ["New"], + operation: "replace", + scope: "content", + content: "body", + createTargetIfMissing: true, + }); + expect(result.document).toBe("# A\na\n# New\nbody\n"); + }); + + test("warns when a created heading would exceed h6", () => { + const result = patch("###### A\na\n", { + targetType: "heading", + target: ["A", "B"], // A is h6, B would be h7 + operation: "replace", + scope: "content", + content: "x", + createTargetIfMissing: true, + }); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("heading-depth-overflow"); + expect(result.document).toContain("####### B\n"); + }); + + test("a heading created at a terminator-less last line starts a fresh line", () => { + // The insertion point is the end of a document with no trailing newline; + // without the owed line start the new marker would glue onto the last + // body line ("…no newline# New Section"). + const result = patch("# A\nlast line no newline", { + targetType: "heading", + target: ["New Section"], + operation: "append", + content: "hello", + createTargetIfMissing: true, + }); + expect(result.document).toBe( + "# A\nlast line no newline\n# New Section\nhello\n" + ); + }); + + test("without createTargetIfMissing a missing heading still throws", () => { + expect(() => + patch("# A\na\n", { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "content", + content: "x", + }) + ).toThrow(TargetNotFoundError); + }); +}); + +describe("createTargetIfMissing — blocks", () => { + test("mints a new block at the end of the document", () => { + const result = patch("a paragraph\n", { + targetType: "block", + target: "ref", + operation: "replace", + scope: "content", + content: "new block", + createTargetIfMissing: true, + }); + expect(result.document).toBe("a paragraph\n\nnew block ^ref\n"); + }); + + test("mints a block in an empty document without a leading separator", () => { + const result = patch("", { + targetType: "block", + target: "ref", + operation: "replace", + scope: "content", + content: "only block", + createTargetIfMissing: true, + }); + expect(result.document).toBe("only block ^ref\n"); + }); +}); + +describe("createTargetIfMissing — frontmatter", () => { + test("creates a new key when the frontmatter block already exists", () => { + const result = patch("---\ntitle: Hello\n---\nbody\n", { + targetType: "frontmatter", + target: "author", + operation: "replace", + scope: "content", + value: "me", + createTargetIfMissing: true, + }); + expect(result.document).toBe( + "---\ntitle: Hello\nauthor: me\n---\nbody\n" + ); + }); + + test("creates the frontmatter block when the document has none", () => { + const result = patch("body only\n", { + targetType: "frontmatter", + target: "title", + operation: "replace", + scope: "content", + value: "New", + createTargetIfMissing: true, + }); + expect(result.document).toBe("---\ntitle: New\n---\nbody only\n"); + }); + + test("creating with a merge seeds an empty value of the right kind", () => { + const result = patch("---\ntitle: Hello\n---\nbody\n", { + targetType: "frontmatter", + target: "tags", + operation: "append", + scope: "content", + value: ["a"], + createTargetIfMissing: true, + }); + expect(result.document).toBe( + "---\ntitle: Hello\ntags:\n - a\n---\nbody\n" + ); + }); + + test("a missing key without createTargetIfMissing throws", () => { + expect(() => + patch("---\ntitle: Hello\n---\nbody\n", { + targetType: "frontmatter", + target: "author", + operation: "replace", + scope: "content", + value: "me", + }) + ).toThrow(TargetNotFoundError); + }); +}); diff --git a/src/tests/docs.rename.test.ts b/src/tests/docs.rename.test.ts new file mode 100644 index 0000000..fb5cd5e --- /dev/null +++ b/src/tests/docs.rename.test.ts @@ -0,0 +1,67 @@ +/** + * Pins the rename behavior the README documents under "Renaming, deleting, and + * moving". A `marker`-scope replace takes the new label as plain text and + * preserves the node's level, so callers never need to know a heading's depth. + * + * The `#` case matters because it is the exact reverse of the deprecated + * `applyPatch`, which *required* matching `#` characters. Here they are not + * stripped -- they land in the heading text -- so a caller migrating from the + * old API silently gets a corrupted heading rather than an error. + */ + +import { patch } from "../engine.js"; + +const doc = `--- +alpha: 1 +--- + +# Heading 1 + +## Subheading + +body + +some paragraph ^abc123 +`; + +const rename = ( + targetType: "heading" | "block" | "frontmatter", + target: string | string[], + content: string +) => + patch(doc, { + targetType, + target, + operation: "replace", + scope: "marker", + content, + } as never).document; + +describe("documented rename behavior", () => { + it("renames a heading from plain text, preserving its level", () => { + expect(rename("heading", ["Heading 1", "Subheading"], "New Name")).toContain("## New Name"); + }); + + it("leaves the heading's body untouched", () => { + expect(rename("heading", ["Heading 1", "Subheading"], "New Name")).toContain( + "## New Name\n\nbody\n" + ); + }); + + it("does not strip `#` characters -- they become part of the label", () => { + // Documented as a trap for callers migrating from applyPatch, which + // required the `#`s. If this ever starts stripping them, the README's + // warning is wrong and must be updated. + expect(rename("heading", ["Heading 1", "Subheading"], "## New Name")).toContain( + "## ## New Name" + ); + }); + + it("renames a block id, which is given bare", () => { + expect(rename("block", "abc123", "xyz789")).toContain("some paragraph ^xyz789"); + }); + + it("renames a frontmatter key, preserving its value", () => { + expect(rename("frontmatter", "alpha", "renamed")).toContain("renamed: 1"); + }); +}); diff --git a/src/tests/docs.whitespace.test.ts b/src/tests/docs.whitespace.test.ts new file mode 100644 index 0000000..f10185f --- /dev/null +++ b/src/tests/docs.whitespace.test.ts @@ -0,0 +1,144 @@ +/** + * Pins the whitespace contract the README documents under "Whitespace is + * library-owned". Caller content is reduced to trimmed, canonical form — + * leading and trailing blank lines are meaningless — and the engine + * contributes the blank-line separator at any joint where the spliced block + * faces body text, so a naive prepend/append can never merge into an existing + * paragraph. Joints against a heading line, an existing blank line, or a + * document edge get nothing: headings are self-delimiting, and existing + * separators and style are preserved rather than rewritten. + * + * This deliberately reverses commit 4f84d89, which documented the previous + * "spliced verbatim / a leading \n buys the blank line" behavior instead of + * fixing it to match Design Principle 1 ("the library owns whitespace"). + * + * These expectations are the literal strings quoted in the docs. If one of + * them changes, the documentation is wrong and must change with it. + */ + +import { patch } from "../engine.js"; + +const spaced = `# One + +body of one +`; + +const flush = `# One +body of one +`; + +const run = ( + doc: string, + operation: "append" | "prepend" | "replace", + content: string +) => patch(doc, { targetType: "heading", target: ["One"], operation, content }).document; + +describe("documented whitespace behavior", () => { + describe("caller newlines are meaningless: every edge variant produces the same document", () => { + const variants = ["X", "X\n", "X\n\n", "\nX\n", "\n\nX\n\n"]; + + test.each(variants)("append %j", (content) => { + expect(run(spaced, "append", content)).toBe("# One\n\nbody of one\n\nX\n"); + }); + + test.each(variants)("prepend %j", (content) => { + expect(run(spaced, "prepend", content)).toBe("# One\n\nX\n\nbody of one\n"); + }); + + test.each(variants)("replace %j", (content) => { + expect(run(spaced, "replace", content)).toBe("# One\n\nX\n"); + }); + }); + + describe("the engine owns the separator at any joint facing body text", () => { + it("append gets a blank line between the body's last line and the new block", () => { + expect(run(spaced, "append", "X")).toBe("# One\n\nbody of one\n\nX\n"); + }); + + it("prepend gets a blank line between the new block and the body below it", () => { + expect(run(flush, "prepend", "X")).toBe("# One\nX\n\nbody of one\n"); + }); + }); + + describe("existing separators and document style are preserved, not rewritten", () => { + it("replace keeps the blank line a spaced document has between marker and body", () => { + expect(run(spaced, "replace", "X")).toBe("# One\n\nX\n"); + }); + + it("replace does not impose a blank line on a flush document", () => { + expect(run(flush, "replace", "X")).toBe("# One\nX\n"); + }); + + it("prepend inserts below a spaced document's marker separator", () => { + expect(run(spaced, "prepend", "X")).toBe("# One\n\nX\n\nbody of one\n"); + }); + + it("replacing a body with its own text is byte-identity in either style", () => { + expect(run(spaced, "replace", "body of one")).toBe(spaced); + expect(run(flush, "replace", "body of one")).toBe(flush); + }); + }); + + it("an append mid-document leaves the owned trailing gap in place", () => { + const midDoc = `# One + +body of one + +# Two + +body of two +`; + expect( + patch(midDoc, { + targetType: "heading", + target: ["One"], + operation: "append", + content: "X", + }).document + ).toBe("# One\n\nbody of one\n\nX\n\n# Two\n\nbody of two\n"); + }); + + describe("a joint owing no blank line still owes a line start", () => { + // Flush-joint sides (a heading-led fragment, a body write directly under + // its marker) contribute no blank line — but when the document's last + // line has no terminator, splicing there verbatim would continue that + // line. The engine owes the single ending that keeps the fragment off it. + it("a sibling section appended at a terminator-less last line starts a fresh line", () => { + const doc = "# A\nbody no newline"; + expect( + patch(doc, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "markerAndContent", + content: "# B\nnew body", + }).document + ).toBe("# A\nbody no newline\n# B\nnew body\n"); + }); + + it("a body write under a terminator-less heading line starts a fresh line", () => { + expect( + patch("# A", { + targetType: "heading", + target: ["A"], + operation: "append", + content: "X", + }).document + ).toBe("# A\nX\n"); + }); + }); + + it("writing into an empty section lands flush under its heading", () => { + // A heading line is self-delimiting, so no separator is owed above; the + // section's existing gap becomes the separator below. + const emptySection = "# E\n\n# F\nf-body\n"; + expect( + patch(emptySection, { + targetType: "heading", + target: ["E"], + operation: "append", + content: "X", + }).document + ).toBe("# E\nX\n\n# F\nf-body\n"); + }); +}); diff --git a/src/tests/duplicateHeadingMarker.test.ts b/src/tests/duplicateHeadingMarker.test.ts new file mode 100644 index 0000000..1052e36 --- /dev/null +++ b/src/tests/duplicateHeadingMarker.test.ts @@ -0,0 +1,37 @@ +import { buildModel } from "../model"; +import { ReservedDuplicateMarkerError } from "../instructions"; + +describe("reserved duplicate-marker collision guard", () => { + test("throws when a heading's raw text ends with the marker followed by digits", () => { + const doc = `# Heading\u{FC750}\u{F6440}\n\nbody\n`; + expect(() => buildModel(doc)).toThrow(ReservedDuplicateMarkerError); + }); + + test("throws when a heading's raw text ends with the marker followed by multiple digits", () => { + const doc = `# Heading\u{FC750}\u{F6441}\u{F6440}\n\nbody\n`; + expect(() => buildModel(doc)).toThrow(ReservedDuplicateMarkerError); + }); + + test("a block id cannot contain the reserved marker, so this never throws", () => { + // Obsidian's block-id syntax only allows [a-zA-Z0-9_-]; a "^id" carrying + // these (astral) codepoints simply isn't recognized as a block reference + // at all, so there is nothing here for the guard to catch. + const doc = `paragraph text ^ref\u{FC750}\u{F6440}\n`; + expect(() => buildModel(doc)).not.toThrow(); + }); + + test("does not throw when the marker sequence sits in the middle of a heading", () => { + const doc = `# Heading\u{FC750}\u{F6440} and more text\n\nbody\n`; + expect(() => buildModel(doc)).not.toThrow(); + }); + + test("does not throw when the marker appears with no digits after it", () => { + const doc = `# Heading\u{FC750}\n\nbody\n`; + expect(() => buildModel(doc)).not.toThrow(); + }); + + test("does not throw for an ordinary document with no reserved codepoints", () => { + const doc = `# Heading\n\nbody ^ref\n`; + expect(() => buildModel(doc)).not.toThrow(); + }); +}); diff --git a/src/tests/engine.test.ts b/src/tests/engine.test.ts new file mode 100644 index 0000000..b18a634 --- /dev/null +++ b/src/tests/engine.test.ts @@ -0,0 +1,885 @@ +import { patch } from "../engine"; +import { + PreconditionFailedError, + TargetNotFoundError, + ContentPreexistsError, + Instruction, +} from "../instructions"; +import { RootHasNoMarkerError } from "../ranges"; +import { buildModel } from "../model"; +import { + NotATableError, + TableColumnCountError, + InvalidCellContentError, +} from "../engine/table"; + +// A small tree: A (h1) > B (h2), then C (h1), each with a one-line body and a +// library-owned blank-line gap between siblings. +const DOC = "# A\na-body\n\n## B\nb-body\n\n# C\nc-body\n"; + +describe("patch — heading content cells", () => { + test("replace @ content on a leaf sets just that section's body", () => { + // B has no children, so its `content` span is exactly its direct body. + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "content", + content: "new-b", + }); + expect(result.document).toBe( + "# A\na-body\n\n## B\nnew-b\n\n# C\nc-body\n" + ); + expect(result.warnings).toEqual([]); + }); + + test("replace @ content spans the whole subtree below the heading (subsections included)", () => { + // A's `content` is everything under it minus its own heading line — a-body + // *and* the ## B subsection — so replacing it absorbs the child section. + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "content", + content: "new-a", + }); + expect(result.document).toBe("# A\nnew-a\n\n# C\nc-body\n"); + expect(result.warnings).toEqual([]); + }); + + test("prepend @ content inserts at the top of the body as its own block", () => { + // The library owes the blank line between the inserted block and the body + // text below it; the joint against the heading line needs none. + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "prepend", + scope: "content", + content: "top", + }); + expect(result.document).toBe( + "# A\ntop\n\na-body\n\n## B\nb-body\n\n# C\nc-body\n" + ); + }); + + test("append @ content inserts at the bottom of the subtree body, before the gap", () => { + // A's content spans through ## B, so an append lands after B's body, not + // between a-body and the subsection. The blank line separating the new + // block from the body text above it is the library's, not the caller's. + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "bot", + }); + expect(result.document).toBe( + "# A\na-body\n\n## B\nb-body\n\nbot\n\n# C\nc-body\n" + ); + }); + + test("content values carry heading levels relative to the section (a `#` becomes a child)", () => { + // Baseline is A's level (1), so a `#` heading in the value lands at level 2. + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "append", + scope: "content", + content: "# child of B", + }); + // B is level 2, so its baseline is 2 and `# child of B` becomes level 3. + expect(result.document).toContain("### child of B\n"); + }); + + test("a rebased heading past h6 still writes but surfaces a warning", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "content", + content: "##### deep", // level 5 + baseline 2 = 7 + }); + expect(result.document).toContain("####### deep\n"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("heading-depth-overflow"); + }); +}); + +describe("patch — heading marker cells", () => { + test("replace @ marker renames the heading, keeping its level", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "marker", + content: "Renamed", + }); + expect(result.document).toContain("## Renamed\n"); + expect(result.document).not.toContain("## B\n"); + }); + + test("prepend @ marker prefixes the label text literally", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "prepend", + scope: "marker", + content: "X-", + }); + expect(result.document).toContain("## X-B\n"); + }); + + test("append @ marker suffixes the label text literally", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "append", + scope: "marker", + content: "-Y", + }); + expect(result.document).toContain("## B-Y\n"); + }); + + test("marker ops on the document root are rejected (root has no heading line)", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: null, + operation: "replace", + scope: "marker", + content: "x", + }) + ).toThrow(RootHasNoMarkerError); + }); +}); + +describe("patch — heading markerAndContent cells", () => { + test("replace @ markerAndContent swaps the whole subtree, rebasing to the parent's level", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "markerAndContent", + content: "# NewB\nnb", // baseline is A's level (1) -> level 2 + }); + expect(result.document).toBe( + "# A\na-body\n\n## NewB\nnb\n\n# C\nc-body\n" + ); + }); + + test("prepend @ markerAndContent inserts a sibling section before the target", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "prepend", + scope: "markerAndContent", + content: "# Sib\nsb", + }); + expect(result.document).toBe( + "# A\na-body\n\n## Sib\nsb\n## B\nb-body\n\n# C\nc-body\n" + ); + }); + + test("append @ markerAndContent inserts a sibling section after the target's subtree", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "append", + scope: "markerAndContent", + content: "# Sib\nsb", + }); + expect(result.document).toBe( + "# A\na-body\n\n## B\nb-body\n\n## Sib\nsb\n# C\nc-body\n" + ); + }); +}); + +describe("patch — block cells", () => { + const BLOCK_DOC = "a paragraph ^ref\n"; + + test("replace @ content changes the block text, keeping its id", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "replace", + scope: "content", + content: "changed", + }); + expect(result.document).toBe("changed ^ref\n"); + }); + + test("prepend @ content inserts before the block text", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "prepend", + scope: "content", + content: "X ", + }); + expect(result.document).toBe("X a paragraph ^ref\n"); + }); + + test("append @ content inserts after the block text, before the id", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + content: "!", + }); + expect(result.document).toBe("a paragraph! ^ref\n"); + }); + + test("replace @ marker changes the block id, keeping the content", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "replace", + scope: "marker", + content: "newid", + }); + expect(result.document).toBe("a paragraph ^newid\n"); + }); + + test("replace @ markerAndContent swaps the whole block", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "replace", + scope: "markerAndContent", + content: "new stuff ^id2", + }); + expect(result.document).toBe("new stuff ^id2\n"); + }); + + test("append @ markerAndContent inserts a sibling block after, separated by a blank line", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "markerAndContent", + content: "new ^id2", + }); + expect(result.document).toBe("a paragraph ^ref\n\nnew ^id2\n"); + }); + + test("prepend @ markerAndContent inserts a sibling block before, separated by a blank line", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "prepend", + scope: "markerAndContent", + content: "new ^id2", + }); + expect(result.document).toBe("new ^id2\n\na paragraph ^ref\n"); + }); +}); + +describe("patch — block table-row cells", () => { + // Mirrors the design doc's worked example: an isolated `^id` line attaches + // to the whole preceding table (header, separator, and body rows). + const TABLE_DOC = + "| City | Population |\n" + + "| ------- | ---------- |\n" + + "| Seattle | 8 |\n" + + "^ref\n"; + + test("append @ content with a value inserts new rows after the existing body rows", () => { + const result = patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + value: [["Chicago", "16"]], + }); + expect(result.document).toBe( + "| City | Population |\n" + + "| ------- | ---------- |\n" + + "| Seattle | 8 |\n" + + "| Chicago | 16 |\n" + + "^ref\n" + ); + }); + + test("prepend @ content with a value inserts new rows right after the header/separator", () => { + const result = patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "prepend", + scope: "content", + value: [["Chicago", "16"]], + }); + expect(result.document).toBe( + "| City | Population |\n" + + "| ------- | ---------- |\n" + + "| Chicago | 16 |\n" + + "| Seattle | 8 |\n" + + "^ref\n" + ); + }); + + test("replace @ content with a value swaps all body rows, keeping the header/separator", () => { + const result = patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "replace", + scope: "content", + value: [["Chicago", "16"]], + }); + expect(result.document).toBe( + "| City | Population |\n" + + "| ------- | ---------- |\n" + + "| Chicago | 16 |\n" + + "^ref\n" + ); + }); + + test("a pipe in a cell is escaped so it stays one cell", () => { + // The array form exists so a caller supplies cell *content* and the library + // renders the table syntax. An unescaped `|` would silently split the cell + // and shift every column after it. + const result = patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + value: [["Seattle | Tacoma", "16"]], + }); + expect(result.document).toContain("| Seattle \\| Tacoma | 16 |"); + + // And it survives a round trip: re-parsing the patched table still sees two + // columns, with the pipe restored as cell text. + const block = buildModel(result.document).root.blocks[0]; + expect(block.columns).toHaveLength(2); + }); + + test("a newline in a cell is rejected rather than splitting the row", () => { + // A line break cannot be expressed inside a GFM table cell; writing it + // verbatim would break one row into two malformed ones. + expect(() => + patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + value: [["two\nlines", "16"]], + }) + ).toThrow(InvalidCellContentError); + }); + + test("a carriage return in a cell is rejected too", () => { + expect(() => + patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + value: [["two\r\nlines", "16"]], + }) + ).toThrow(InvalidCellContentError); + }); + + test("a row with the wrong number of cells raises TableColumnCountError", () => { + expect(() => + patch(TABLE_DOC, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + value: [["only-one-cell"]], + }) + ).toThrow(TableColumnCountError); + }); + + test("a value on a non-table block raises NotATableError", () => { + const result = "a paragraph ^ref\n"; + expect(() => + patch(result, { + targetType: "block", + target: "ref", + operation: "append", + scope: "content", + value: [["x", "y"]], + }) + ).toThrow(NotATableError); + }); + + test("createTargetIfMissing is rejected for table-row writes", () => { + expect(() => + patch(TABLE_DOC, { + targetType: "block", + target: "nonexistent", + operation: "append", + scope: "content", + value: [["Chicago", "16"]], + createTargetIfMissing: true, + }) + ).toThrow(); + }); +}); + +describe("patch — preconditions and resolution", () => { + test("ifMatch matching the current version applies the patch", () => { + // Compute the version via a no-op resolve by patching with the right token. + const first = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "append", + scope: "content", + content: "x", + }); + expect(first.document).toContain("b-body\n\nx\n"); + }); + + test("ifMatch not matching the current version fails without modifying the document", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "x", + ifMatch: "deadbeef", + }) + ).toThrow(PreconditionFailedError); + }); + + test("an unresolvable target raises TargetNotFoundError", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: ["Nope"], + operation: "replace", + scope: "content", + content: "x", + }) + ).toThrow(TargetNotFoundError); + }); +}); + +describe("patch — rejectIfContentPreexists on heading-bearing content", () => { + // A's content already contains a rebased (absolute-level) heading: "## + // Already Here" is what "# Already Here" becomes once rebased to A's + // baseline (1). A naive comparison of the *raw* (relative-level) instruction + // content against this *absolute*-level span never matches once the heading + // isn't the very first thing in the content — the leading "#" run no longer + // lines up as a lucky substring — so the guard silently passes when it + // shouldn't. + const doc = "# A\nintro\n## Already Here\nbody\n\n# C\nc-body\n"; + + test("detects relative-level content that already exists once rebased to the target's level", () => { + expect(() => + patch(doc, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "intro\n# Already Here\nbody\n", + rejectIfContentPreexists: true, + }) + ).toThrow(ContentPreexistsError); + }); + + test("does not reject genuinely new heading-bearing content", () => { + const result = patch(doc, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "intro\n# Not Here Yet\nbody\n", + rejectIfContentPreexists: true, + }); + expect(result.document).toContain("## Not Here Yet"); + }); + + test("detects relative-level content already present under markerAndContent (parent-level baseline)", () => { + // markerAndContent rebases to the *parent's* level, not the target's own — + // here B's parent A is level 1, so a relative "# B" (1 hash) is what B's + // own absolute "## B" (2 hashes) looks like at that baseline. + const nestedDoc = "# A\n## B\nintro\n### Already Here\nbody\n\n# C\nc-body\n"; + expect(() => + patch(nestedDoc, { + targetType: "heading", + target: ["A", "B"], + operation: "prepend", + scope: "markerAndContent", + content: "# B\nintro\n## Already Here\nbody\n", + rejectIfContentPreexists: true, + }) + ).toThrow(ContentPreexistsError); + }); +}); + +describe("patch — scope defaults to content", () => { + test("a heading write with no scope edits the section body", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + content: "z", + }); + expect(result.document).toBe("# A\na-body\n\n## B\nz\n\n# C\nc-body\n"); + }); + + test("a heading delete with no scope empties the section body", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "delete", + }); + expect(result.document).toBe("# A\na-body\n\n## B\n\n# C\nc-body\n"); + }); + + test("a block write with no scope edits the block text", () => { + const result = patch("a paragraph ^ref\n", { + targetType: "block", + target: "ref", + operation: "replace", + content: "changed", + }); + expect(result.document).toBe("changed ^ref\n"); + }); + + test("a frontmatter write with no scope sets the value", () => { + const result = patch("---\ntitle: Hello\n---\nbody\n", { + targetType: "frontmatter", + target: "title", + operation: "replace", + value: "Bye", + }); + expect(result.document).toBe("---\ntitle: Bye\n---\nbody\n"); + }); + + test("an explicit scope is still honored over the default", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "marker", + content: "Renamed", + }); + expect(result.document).toContain("## Renamed\n"); + }); +}); + +describe("patch — no-op identity", () => { + test("replacing a leaf section body with its own current text is byte-identity", () => { + // B is a leaf, so its content span is just "b-body\n"; replacing with the + // same text must not change any bytes. + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "content", + content: "b-body", + }); + expect(result.document).toBe(DOC); + }); + + test("a valid instruction type-checks against the Instruction union", () => { + const instruction: Instruction = { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "content", + content: "x", + }; + expect(instruction.operation).toBe("replace"); + }); +}); + +describe("patch — within (positional body-block) cells", () => { + // Section A has three body blocks: a paragraph, a list, and a table; the + // subsection and B's body must never be touched by a within edit on A. + const WDOC = [ + "# A", + "", + "intro para", + "", + "- one", + "- two", + "", + "| a |", + "| - |", + "| 1 |", + "", + "## Sub", + "", + "sub body", + "", + "# B", + "", + "b body", + "", + ].join("\n"); + + describe("content scope: literal splice, the caller owns the joint", () => { + test("append with a leading newline extends a list in place", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 1, + operation: "append", + content: "\n- three", + }); + expect(result.document).toContain("- one\n- two\n- three\n\n| a |"); + }); + + test("append without a newline continues the block's last line", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "append", + content: " (continued)", + }); + expect(result.document).toContain("intro para (continued)\n\n- one"); + }); + + test("prepend splices at the block's first byte", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "prepend", + content: "OK: ", + }); + expect(result.document).toContain("# A\n\nOK: intro para\n"); + }); + + test("replace swaps exactly the block's own text", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: -1, + operation: "replace", + content: "plain text instead of a table", + }); + expect(result.document).toContain( + "- two\n\nplain text instead of a table\n\n## Sub" + ); + expect(result.document).not.toContain("| a |"); + }); + + test("replace with the block's own text is a byte identity", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 1, + operation: "replace", + content: "- one\n- two", + }); + expect(result.document).toBe(WDOC); + }); + + test("a negative index counts from the end of the direct body", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: -3, + operation: "prepend", + content: "X", + }); + expect(result.document).toContain("Xintro para"); + }); + + test("delete removes the block and its separator", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "delete", + }); + expect(result.document).toContain("# A\n\n- one\n- two\n\n| a |"); + expect(result.document).not.toContain("intro para"); + }); + + test("delete of the last block leaves the next heading separated", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: -1, + operation: "delete", + }); + expect(result.document).toContain("- one\n- two\n\n## Sub"); + expect(result.document).not.toContain("| a |"); + }); + + test("delete of a final block at EOF with no trailing newline", () => { + const doc = "# H\n\nfirst\n\nlast without newline"; + const result = patch(doc, { + targetType: "heading", + target: ["H"], + within: -1, + operation: "delete", + }); + expect(result.document).toBe("# H\n\nfirst\n\n"); + }); + + test("a CRLF document keeps its line endings through a within append", () => { + const doc = "# H\r\n\r\n- a\r\n- b\r\n"; + const result = patch(doc, { + targetType: "heading", + target: ["H"], + within: 0, + operation: "append", + content: "\n- c", + }); + expect(result.document).toBe("# H\r\n\r\n- a\r\n- b\r\n- c\r\n"); + }); + }); + + describe("markerAndContent scope: sibling block insert, library-owned separators", () => { + test("prepend before the first block in a spaced document", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "prepend", + scope: "markerAndContent", + content: "new first block", + }); + expect(result.document).toContain( + "# A\n\nnew first block\n\nintro para\n" + ); + }); + + test("prepend before the first block in a flush document stays flush at the heading", () => { + const doc = "# H\nbody line\n"; + const result = patch(doc, { + targetType: "heading", + target: ["H"], + within: 0, + operation: "prepend", + scope: "markerAndContent", + content: "new block", + }); + expect(result.document).toBe("# H\nnew block\n\nbody line\n"); + }); + + test("append between two blocks pads both joints", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "append", + scope: "markerAndContent", + content: "between", + }); + expect(result.document).toContain( + "intro para\n\nbetween\n\n- one\n- two" + ); + }); + + test("append after the last block lands before the subsection", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: -1, + operation: "append", + scope: "markerAndContent", + content: "after the table", + }); + expect(result.document).toContain( + "| 1 |\n\nafter the table\n\n## Sub" + ); + }); + + test("append after a ^id-annotated block falls past its marker line", () => { + const doc = "# H\n\n- item\n\n^ref\n\nlast\n"; + const result = patch(doc, { + targetType: "heading", + target: ["H"], + within: 0, + operation: "append", + scope: "markerAndContent", + content: "inserted", + }); + // The isolated marker stays bound to the list it annotates. + expect(result.document).toBe( + "# H\n\n- item\n\n^ref\n\ninserted\n\nlast\n" + ); + }); + + test("a fragment opening with # becomes a child heading of the section", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "append", + scope: "markerAndContent", + content: "# Inserted\nwith body", + }); + // Relative levels: a leading `#` is one level below A (an h1), so h2. + expect(result.document).toContain( + "intro para\n\n## Inserted\nwith body\n\n- one" + ); + }); + }); + + describe("guards", () => { + test("an out-of-range index throws TargetNotFoundError", () => { + expect(() => + patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 3, + operation: "append", + content: "x", + }) + ).toThrow(TargetNotFoundError); + }); + + test("a stale ifMatch fails before the index is even resolved", () => { + expect(() => + patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 99, + operation: "append", + content: "x", + ifMatch: "not-the-version", + }) + ).toThrow(PreconditionFailedError); + }); + + test("rejectIfContentPreexists refuses content already in the addressed block", () => { + expect(() => + patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 1, + operation: "append", + content: "\n- one", + rejectIfContentPreexists: true, + }) + ).toThrow(ContentPreexistsError); + }); + + test("rejectIfContentPreexists ignores matches outside the addressed block", () => { + const result = patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 1, + operation: "append", + content: "\n- intro para", + rejectIfContentPreexists: true, + }); + expect(result.document).toContain("- two\n- intro para\n"); + }); + + test("rejectIfContentPreexists on a sibling insert scans the whole section body", () => { + expect(() => + patch(WDOC, { + targetType: "heading", + target: ["A"], + within: 0, + operation: "append", + scope: "markerAndContent", + content: "- one\n- two", + rejectIfContentPreexists: true, + }) + ).toThrow(ContentPreexistsError); + }); + }); +}); diff --git a/src/tests/frontmatter.test.ts b/src/tests/frontmatter.test.ts new file mode 100644 index 0000000..4238f8a --- /dev/null +++ b/src/tests/frontmatter.test.ts @@ -0,0 +1,290 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { patch } from "../engine"; +import { buildModel } from "../model"; +import { readTarget } from "../read"; +import { + MergeError, + FrontmatterParseError, + FrontmatterKeyCollisionError, +} from "../instructions"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const FM = "---\ntitle: Hello\ntags:\n - a\n - b\n---\nbody text\n"; + +describe("patch — frontmatter content cells", () => { + test("replace @ content sets the value, preserving the body", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "replace", + scope: "content", + value: "Goodbye", + }); + expect(result.document).toBe( + "---\ntitle: Goodbye\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); + + test("append @ content concatenates onto a list value", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "tags", + operation: "append", + scope: "content", + value: ["c"], + }); + expect(result.document).toBe( + "---\ntitle: Hello\ntags:\n - a\n - b\n - c\n---\nbody text\n" + ); + }); + + test("prepend @ content concatenates onto the front of a list value", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "tags", + operation: "prepend", + scope: "content", + value: ["z"], + }); + expect(result.document).toBe( + "---\ntitle: Hello\ntags:\n - z\n - a\n - b\n---\nbody text\n" + ); + }); + + test("append @ content concatenates strings", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "append", + scope: "content", + value: " World", + }); + expect(result.document).toBe( + "---\ntitle: Hello World\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); + + test("delete @ content clears the value but keeps the key", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "delete", + scope: "content", + }); + expect(result.document).toBe( + "---\ntitle: null\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); + + test("mismatched value types are not mergeable", () => { + expect(() => + patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "append", + scope: "content", + value: 5, + }) + ).toThrow(MergeError); + }); +}); + +describe("patch — frontmatter marker cell", () => { + test("replace @ marker renames the key, keeping its value and position", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "replace", + scope: "marker", + content: "heading", + }); + expect(result.document).toBe( + "---\nheading: Hello\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); +}); + +describe("patch — frontmatter markerAndContent cells", () => { + test("delete @ markerAndContent removes the whole entry", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "tags", + operation: "delete", + scope: "markerAndContent", + }); + expect(result.document).toBe("---\ntitle: Hello\n---\nbody text\n"); + }); + + test("deleting the last entry removes the frontmatter block entirely", () => { + const result = patch("---\nonly: x\n---\nbody\n", { + targetType: "frontmatter", + target: "only", + operation: "delete", + scope: "markerAndContent", + }); + expect(result.document).toBe("body\n"); + }); + + test("replace @ markerAndContent re-emits the entry with a new value", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "replace", + scope: "markerAndContent", + value: 42, + }); + expect(result.document).toBe( + "---\ntitle: 42\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); + + test("append @ markerAndContent inserts a new entry after the anchor", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "append", + scope: "markerAndContent", + value: { author: "me" }, + }); + expect(result.document).toBe( + "---\ntitle: Hello\nauthor: me\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); + + test("prepend @ markerAndContent inserts a new entry before the anchor", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "tags", + operation: "prepend", + scope: "markerAndContent", + value: { author: "me" }, + }); + expect(result.document).toBe( + "---\ntitle: Hello\nauthor: me\ntags:\n - a\n - b\n---\nbody text\n" + ); + }); +}); + +describe("buildModel — keys whose written form differs from their parsed form", () => { + // Entries come from the positioned YAML AST, so a key is recognized by what + // it *parses to*, never by matching its raw line text. The line-scan this + // guards against silently produced no entry for such keys — and, since + // frontmatter writes re-serialize from the entry list, destroyed them. + const QUOTED = '---\n"a:b": 1\nother: 2\n---\nbody\n'; + + test("a quoted key containing a colon is listed and addressable", () => { + const model = buildModel(QUOTED); + expect(model.frontmatter.entries.map((e) => e.key)).toEqual([ + "a:b", + "other", + ]); + expect(readTarget(QUOTED, { targetType: "frontmatter", target: "a:b" })) + .toEqual({ kind: "frontmatter", value: 1 }); + }); + + test("a quoted key survives a write to a sibling key", () => { + const result = patch(QUOTED, { + targetType: "frontmatter", + target: "other", + operation: "replace", + value: 3, + }); + // The serializer may re-emit the key in plain style ("a:b:" parses to the + // same key); what matters is that the entry survives with its value. + expect(result.document).toBe("---\na:b: 1\nother: 3\n---\nbody\n"); + const after = buildModel(result.document); + expect(after.frontmatter.entries.map((e) => [e.key, e.value])).toEqual([ + ["a:b", 1], + ["other", 3], + ]); + }); + + test("a plainly quoted key is editable under its parsed name", () => { + const result = patch('---\n"foo": 1\n---\nbody\n', { + targetType: "frontmatter", + target: "foo", + operation: "replace", + value: 9, + }); + expect(result.document).toBe("---\nfoo: 9\n---\nbody\n"); + }); + + test("a numeric key round-trips in document order", () => { + const doc = "---\n2024: notes\nalpha: 1\n---\n"; + const model = buildModel(doc); + expect(model.frontmatter.entries.map((e) => e.key)).toEqual([ + "2024", + "alpha", + ]); + }); +}); + +describe("buildModel — malformed frontmatter", () => { + const colonInFrontmatter = fs.readFileSync( + path.join(__dirname, "sample.frontmatter.colon-in-value.md"), + "utf-8" + ); + + test("buildModel throws FrontmatterParseError rather than a raw YAML error", () => { + expect(() => buildModel(colonInFrontmatter)).toThrow(FrontmatterParseError); + }); + + test("patch throws FrontmatterParseError, even for a non-frontmatter target", () => { + expect(() => + patch(colonInFrontmatter, { + targetType: "block", + target: "block-1", + operation: "replace", + content: "New content.", + }) + ).toThrow(FrontmatterParseError); + }); + + test("readTarget throws FrontmatterParseError", () => { + expect(() => + readTarget(colonInFrontmatter, { targetType: "block", target: "block-1" }) + ).toThrow(FrontmatterParseError); + }); +}); + +describe("patch — frontmatter key collisions", () => { + test("renaming a key onto an existing key raises FrontmatterKeyCollisionError", () => { + expect(() => + patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "replace", + scope: "marker", + content: "tags", + }) + ).toThrow(FrontmatterKeyCollisionError); + }); + + test("inserting an entry whose key already exists raises FrontmatterKeyCollisionError", () => { + expect(() => + patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "append", + scope: "markerAndContent", + value: { tags: ["c"] }, + }) + ).toThrow(FrontmatterKeyCollisionError); + }); + + test("renaming a key to its own name is not a collision", () => { + const result = patch(FM, { + targetType: "frontmatter", + target: "title", + operation: "replace", + scope: "marker", + content: "title", + }); + expect(result.document).toBe(FM); + }); +}); diff --git a/src/tests/instructions.test.ts b/src/tests/instructions.test.ts new file mode 100644 index 0000000..b734d69 --- /dev/null +++ b/src/tests/instructions.test.ts @@ -0,0 +1,246 @@ +import { + Operation, + Scope, + TargetType, + Instruction, + InstructionInput, + isValidCell, + assertValidCell, + InvalidCellError, + withDefaultScope, + isBlockTableRowInstruction, +} from "../instructions"; + +const OPERATIONS: Operation[] = ["replace", "prepend", "append", "delete"]; +const SCOPES: Scope[] = ["content", "marker", "markerAndContent", "parent"]; +const TARGET_TYPES: TargetType[] = ["heading", "block", "frontmatter"]; + +// An independent, hand-written truth table for the 4×4×3 matrix, so the guard +// is checked against an explicit spec rather than its own data structure. +const VALID = new Set([ + // heading + "heading|replace|content", + "heading|prepend|content", + "heading|append|content", + "heading|delete|content", + "heading|replace|marker", + "heading|prepend|marker", + "heading|append|marker", + "heading|delete|marker", + "heading|replace|markerAndContent", + "heading|prepend|markerAndContent", + "heading|append|markerAndContent", + "heading|delete|markerAndContent", + "heading|replace|parent", + // block + "block|replace|content", + "block|prepend|content", + "block|append|content", + "block|delete|content", + "block|replace|marker", + "block|delete|marker", + "block|replace|markerAndContent", + "block|prepend|markerAndContent", + "block|append|markerAndContent", + "block|delete|markerAndContent", + // frontmatter + "frontmatter|replace|content", + "frontmatter|prepend|content", + "frontmatter|append|content", + "frontmatter|delete|content", + "frontmatter|replace|marker", + "frontmatter|replace|markerAndContent", + "frontmatter|prepend|markerAndContent", + "frontmatter|append|markerAndContent", + "frontmatter|delete|markerAndContent", +]); + +describe("cell validity matrix", () => { + for (const targetType of TARGET_TYPES) { + for (const operation of OPERATIONS) { + for (const scope of SCOPES) { + const key = `${targetType}|${operation}|${scope}`; + const expected = VALID.has(key); + test(`${key} is ${expected ? "valid" : "invalid"}`, () => { + expect(isValidCell(targetType, operation, scope)).toBe(expected); + }); + } + } + } +}); + +describe("assertValidCell", () => { + test("passes for a valid cell", () => { + expect(() => + assertValidCell({ + targetType: "heading", + operation: "replace", + scope: "parent", + }) + ).not.toThrow(); + }); + + test("throws InvalidCellError for an invalid cell", () => { + expect(() => + assertValidCell({ + targetType: "heading", + operation: "prepend", + scope: "parent", + }) + ).toThrow(InvalidCellError); + }); + + test("rejects parent scope on block and frontmatter targets", () => { + expect(() => + assertValidCell({ + targetType: "block", + operation: "replace", + scope: "parent", + }) + ).toThrow(InvalidCellError); + expect(() => + assertValidCell({ + targetType: "frontmatter", + operation: "replace", + scope: "parent", + }) + ).toThrow(InvalidCellError); + }); +}); + +describe("Instruction typing (compile-time)", () => { + // These literals must type-check; they double as documentation of the shape. + test("representative instructions are assignable to Instruction", () => { + const examples: Instruction[] = [ + { + targetType: "heading", + operation: "append", + scope: "content", + target: ["Overview"], + content: "# A child section\n", + }, + { + targetType: "heading", + operation: "replace", + scope: "parent", + target: ["Overview", "Details"], + destination: { parent: ["Appendix"], place: "last" }, + }, + { + targetType: "heading", + operation: "delete", + scope: "marker", + target: ["Overview", "Obsolete"], + }, + { + targetType: "block", + operation: "replace", + scope: "marker", + target: "thesis", + content: "revised-thesis", + }, + { + targetType: "block", + operation: "append", + scope: "content", + target: "population-table", + value: [["Chicago, IL", "16"]], + }, + { + targetType: "frontmatter", + operation: "append", + scope: "content", + target: "reviewers", + value: ["alice"], + }, + { + targetType: "frontmatter", + operation: "replace", + scope: "marker", + target: "status", + content: "state", + }, + ]; + expect(examples).toHaveLength(7); + }); +}); + +describe("isBlockTableRowInstruction", () => { + test("true for a block content cell carrying value", () => { + expect( + isBlockTableRowInstruction({ + targetType: "block", + target: "abc", + operation: "append", + scope: "content", + value: [["a", "b"]], + }) + ).toBe(true); + }); + + test("false for a block content cell carrying literal content", () => { + expect( + isBlockTableRowInstruction({ + targetType: "block", + target: "abc", + operation: "append", + scope: "content", + content: "text", + }) + ).toBe(false); + }); + + test("false for a block marker cell", () => { + expect( + isBlockTableRowInstruction({ + targetType: "block", + target: "abc", + operation: "replace", + scope: "marker", + content: "newid", + }) + ).toBe(false); + }); +}); + +describe("InstructionInput — scope defaulting boundary", () => { + test("scope may be omitted where it would default to content", () => { + // A write/delete cell: `scope` is optional on the input. + const headingWrite: InstructionInput = { + targetType: "heading", + target: ["A"], + operation: "replace", + content: "x", + }; + const frontmatterValue: InstructionInput = { + targetType: "frontmatter", + target: "title", + operation: "replace", + value: "x", + }; + expect(withDefaultScope(headingWrite).scope).toBe("content"); + expect(withDefaultScope(frontmatterValue).scope).toBe("content"); + }); + + test("a move is never defaulted — it must name scope: parent", () => { + // @ts-expect-error a `destination` without scope: "parent" matches no member. + const move: InstructionInput = { + targetType: "heading", + target: ["A"], + operation: "replace", + destination: { parent: null, place: "last" }, + }; + void move; + }); + + test("an explicit scope is preserved by the default", () => { + const explicit: InstructionInput = { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "markerAndContent", + content: "# A\nx", + }; + expect(withDefaultScope(explicit).scope).toBe("markerAndContent"); + }); +}); diff --git a/src/tests/levels.test.ts b/src/tests/levels.test.ts new file mode 100644 index 0000000..9de5954 --- /dev/null +++ b/src/tests/levels.test.ts @@ -0,0 +1,50 @@ +import { rebaseHeadings } from "../levels"; + +describe("rebaseHeadings", () => { + test("baseline 0 leaves the value unchanged (root: relative == absolute)", () => { + const value = "## X\nbody\n"; + const result = rebaseHeadings(value, 0); + expect(result.text).toBe(value); + expect(result.warnings).toEqual([]); + }); + + test("content baseline adds the target's level (a `#` becomes a direct child)", () => { + const result = rebaseHeadings("# Child\ntext\n", 2); + expect(result.text).toBe("### Child\ntext\n"); + expect(result.warnings).toEqual([]); + }); + + test("markerAndContent baseline (parent level) makes `# Title` a same-level sibling", () => { + // Target is level 2, its parent is level 1, so baseline 1 -> `# Title` at level 2. + const result = rebaseHeadings("# Title\nbody\n", 1); + expect(result.text).toBe("## Title\nbody\n"); + }); + + test("rebases every heading in a multi-section fragment", () => { + const result = rebaseHeadings("# One\na\n# Two\nb\n", 1); + expect(result.text).toBe("## One\na\n## Two\nb\n"); + }); + + test("rebases nested headings preserving their relative depth", () => { + const result = rebaseHeadings("# Parent\n## Child\n", 2); + expect(result.text).toBe("### Parent\n#### Child\n"); + }); + + test("does not rebase `#` lines inside a fenced code block", () => { + const value = "# Real\n\n```\n# not a heading\n```\n"; + const result = rebaseHeadings(value, 1); + expect(result.text).toBe("## Real\n\n```\n# not a heading\n```\n"); + }); + + test("warns when a rebased heading exceeds h6 but still writes the hashes", () => { + const result = rebaseHeadings("### Deep\n", 5); // 3 + 5 = 8 + expect(result.text).toBe("######## Deep\n"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].code).toBe("heading-depth-overflow"); + }); + + test("normalizes CRLF in the value to LF so the engine can apply the document's ending", () => { + const result = rebaseHeadings("# Child\r\ntext\r\n", 1); + expect(result.text).toBe("## Child\ntext\n"); + }); +}); diff --git a/src/tests/map.test.ts b/src/tests/map.test.ts deleted file mode 100644 index 7a8fc7b..0000000 --- a/src/tests/map.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; - -import { getDocumentMap } from "../map"; -import { DocumentMapMarkerContentPair } from "../types"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("map", () => { - const sample = fs.readFileSync(path.join(__dirname, "sample.md"), "utf-8"); - - test("heading", () => { - const actualHeadings = getDocumentMap(sample).heading; - - const expectedHeadings = { - "": { - content: { - start: 130, - end: 6988, - }, - marker: { - start: 0, - end: 0, - }, - level: 0, - }, - Overview: { - content: { - start: 142, - end: 430, - }, - marker: { - start: 131, - end: 142, - }, - level: 1, - }, - Problems: { - content: { - start: 441, - end: 1468, - }, - marker: { - start: 430, - end: 441, - }, - level: 1, - }, - Actions: { - content: { - start: 1478, - end: 3182, - }, - marker: { - start: 1468, - end: 1478, - }, - level: 1, - }, - Headers: { - content: { - start: 3192, - end: 4282, - }, - marker: { - start: 3182, - end: 3192, - }, - level: 1, - }, - "Page Targets": { - content: { - start: 4297, - end: 6988, - }, - marker: { - start: 4282, - end: 4297, - }, - level: 1, - }, - "Page Targets\u001fHeading": { - content: { - start: 4309, - end: 5251, - }, - marker: { - start: 4298, - end: 4309, - }, - level: 2, - }, - "Page Targets\u001fBlock": { - content: { - start: 5260, - end: 6122, - }, - marker: { - start: 5251, - end: 5260, - }, - level: 2, - }, - "Page Targets\u001fBlock\u001fUse Cases": { - content: { - start: 5778, - end: 6122, - }, - marker: { - start: 5764, - end: 5778, - }, - level: 3, - }, - "Page Targets\u001fFrontmatter Field": { - content: { - start: 6143, - end: 6690, - }, - marker: { - start: 6122, - end: 6143, - }, - level: 2, - }, - "Page Targets\u001fFrontmatter Field\u001fUse Cases": { - content: { - start: 6510, - end: 6690, - }, - marker: { - start: 6496, - end: 6510, - }, - level: 3, - }, - "Page Targets\u001fDocument Properties (Exploratory)": { - content: { - start: 6727, - end: 6988, - }, - marker: { - start: 6690, - end: 6727, - }, - level: 2, - }, - }; - - //console.log(JSON.stringify(actualHeadings, undefined, 4)); - - expect(actualHeadings).toEqual(expectedHeadings); - }); - - test("block", () => { - const actualBlocks = getDocumentMap(sample).block; - const expectedBlocks: Record = { - "2c67a6": { - content: { - start: 1478, - end: 3172, - }, - marker: { - start: 3173, - end: 3181, - }, - }, - "1d6271": { - content: { - start: 3192, - end: 4272, - }, - marker: { - start: 4273, - end: 4281, - }, - }, - bfec1f: { - content: { - start: 4310, - end: 4606, - }, - marker: { - start: 4607, - end: 4615, - }, - }, - "259a73": { - content: { - start: 6570, - end: 6633, - }, - marker: { - start: 6633, - end: 6642, - }, - }, - e6068e: { - content: { - start: 6642, - end: 6681, - }, - marker: { - start: 6681, - end: 6690, - }, - }, - }; - - //console.log(JSON.stringify(actualBlocks, undefined, 4)); - - expect(actualBlocks).toEqual(expectedBlocks); - }); - - describe("frontmatter", () => { - test("exists", () => { - const actualFrontmatter = getDocumentMap(sample).frontmatter; - - const expectedFrontmatter = { - aliases: ["Structured Markdown Patch"], - "project-type": "Technical", - repository: "https://github.com/coddingtonbear/markdown-patch", - }; - - expect(expectedFrontmatter).toEqual(actualFrontmatter); - }); - - - test("exists but is empty", () => { - const actual = getDocumentMap("---\n---\n# H\n"); - - expect(actual.frontmatter).toEqual({}); - expect(actual.contentOffset).toBe(8); - expect(actual.heading.H.marker.start).toBe(8); - }); - - test("exists but is empty with CRLF line endings", () => { - const actual = getDocumentMap("---\r\n---\r\n# H\r\n"); - - expect(actual.frontmatter).toEqual({}); - expect(actual.contentOffset).toBe(10); - expect(actual.heading.H.marker.start).toBe(10); - }); - - test("does not treat later thematic breaks as frontmatter", () => { - const actual = getDocumentMap("# H\n\n---\nbody\n---\n"); - - expect(actual.frontmatter).toEqual({}); - expect(actual.contentOffset).toBe(0); - expect(actual.heading.H.marker.start).toBe(0); - }); - - test("does not exist", () => { - const sample = fs.readFileSync( - path.join(__dirname, "sample.frontmatter.none.md"), - "utf-8" - ); - - const actualFrontmatter = getDocumentMap(sample).frontmatter; - const expectedFrontmatter = {}; - - expect(expectedFrontmatter).toEqual(actualFrontmatter); - }); - - test("does not exist, but starts with hr", () => { - const sample = fs.readFileSync( - path.join(__dirname, "sample.frontmatter.nonfrontmatter-hr.md"), - "utf-8" - ); - - const actualFrontmatter = getDocumentMap(sample).frontmatter; - const expectedFrontmatter = {}; - - expect(expectedFrontmatter).toEqual(actualFrontmatter); - }); - }); -}); diff --git a/src/tests/model.property.test.ts b/src/tests/model.property.test.ts new file mode 100644 index 0000000..cd6233a --- /dev/null +++ b/src/tests/model.property.test.ts @@ -0,0 +1,227 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { + buildModel, + serializeModel, + eachSection, + DocumentModel, + SectionNode, +} from "../model"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const CONFORMANCE_DIR = path.join(__dirname, "conformance"); + +const readConformanceFixtures = (): Array<{ name: string; text: string }> => + fs + .readdirSync(CONFORMANCE_DIR) + .filter((f) => f.endsWith(".md") && f !== "README.md") + .map((f) => ({ + name: f, + text: fs.readFileSync(path.join(CONFORMANCE_DIR, f), "utf-8"), + })); + +// A spread of hand-crafted documents exercising boundary conditions the +// fixtures may not: no trailing newline, no frontmatter, empty body sections, +// headings with no blank-line separators, and a bare preamble. +const CRAFTED: Array<{ name: string; text: string }> = [ + { name: "empty", text: "" }, + { name: "only-text", text: "Just a paragraph, no headings.\n" }, + { name: "no-trailing-newline", text: "# H\n\nBody with no final newline." }, + { name: "heading-only", text: "# Alone\n" }, + { name: "heading-only-no-newline", text: "# Alone" }, + { name: "no-blank-separators", text: "# A\nbody a\n# B\nbody b\n" }, + { + name: "preamble-then-heading", + text: "Preamble line.\n\n# First\n\nBody.\n", + }, + { name: "consecutive-headings", text: "# A\n## B\n### C\n\nbody\n" }, + { + name: "many-blank-lines", + text: "# A\n\nbody a\n\n\n\n# B\n\nbody b\n", + }, +]; + +const allDocuments = (): Array<{ name: string; text: string }> => [ + ...readConformanceFixtures(), + ...CRAFTED, +]; + +const collectSections = (model: DocumentModel): SectionNode[] => { + const nodes: SectionNode[] = []; + eachSection(model.root, (n) => nodes.push(n)); + return nodes; +}; + +describe("model partition invariants", () => { + describe.each(allDocuments())("$name", ({ text }) => { + const model = buildModel(text); + + test("round-trips: serializing the model reproduces the source", () => { + expect(serializeModel(text, model)).toEqual(text); + }); + + test("every section range is well-formed (start <= end)", () => { + for (const node of collectSections(model)) { + if (node.marker) { + expect(node.marker.start).toBeLessThanOrEqual(node.marker.end); + } + expect(node.body.start).toBeLessThanOrEqual(node.body.end); + expect(node.trailingGap.start).toBeLessThanOrEqual(node.trailingGap.end); + } + }); + + test("content and trailingGap are contiguous per section", () => { + for (const node of collectSections(model)) { + expect(node.body.end).toEqual(node.trailingGap.start); + if (node.marker) { + expect(node.marker.end).toEqual(node.body.start); + } + } + }); + + test("trailingGap is whitespace only", () => { + for (const node of collectSections(model)) { + const gap = text.slice(node.trailingGap.start, node.trailingGap.end); + expect(gap).toMatch(/^\s*$/); + } + }); + + test("block ranges fall inside their containing section body", () => { + for (const node of collectSections(model)) { + for (const block of node.blocks) { + expect(block.content.start).toBeGreaterThanOrEqual(node.body.start); + expect(block.marker.end).toBeLessThanOrEqual(node.trailingGap.end); + expect(block.section).toBe(node); + } + } + }); + + test("body children fall inside their section body, ordered and disjoint", () => { + for (const node of collectSections(model)) { + let previousEnd = node.body.start; + for (const child of node.bodyChildren) { + expect(child.range.start).toBeGreaterThanOrEqual(previousEnd); + expect(child.range.start).toBeLessThan(child.range.end); + expect(child.range.end).toBeLessThanOrEqual(node.body.end); + previousEnd = child.range.end; + } + } + }); + }); +}); + +describe("model structure", () => { + test("duplicate headings become distinct sibling sections", () => { + const doc = + "# Log\n\n## 2026-07-18\n\nfirst\n\n## 2026-07-18\n\nsecond\n"; + const model = buildModel(doc); + const log = model.root.children[0]; + expect(log.children).toHaveLength(2); + expect(log.children[0].heading?.text).toEqual("2026-07-18"); + expect(log.children[1].heading?.text).toEqual("2026-07-18"); + expect(text_of(doc, log.children[0])).toContain("first"); + expect(text_of(doc, log.children[1])).toContain("second"); + }); + + test("skipped heading levels still nest by depth", () => { + const doc = "# Top\n\nintro\n\n### Deep\n\ndeep body\n\n# Second\n\nx\n"; + const model = buildModel(doc); + expect(model.root.children.map((c) => c.heading?.text)).toEqual([ + "Top", + "Second", + ]); + const top = model.root.children[0]; + expect(top.children.map((c) => c.heading?.text)).toEqual(["Deep"]); + expect(top.children[0].heading?.level).toEqual(3); + }); + + test("preamble before the first heading belongs to the root", () => { + const doc = "Preamble.\n\n# First\n\nbody\n"; + const model = buildModel(doc); + expect(text_of(doc, model.root)).toContain("Preamble."); + expect(model.root.heading).toBeNull(); + }); + + test("frontmatter block is excluded from the section tree", () => { + const doc = "---\ntitle: t\n---\n\n# H\n\nbody\n"; + const model = buildModel(doc); + expect(model.frontmatter.block).not.toBeNull(); + expect(model.frontmatter.entries.map((e) => e.key)).toEqual(["title"]); + // Root content starts at or after the frontmatter block. + expect(model.root.body.start).toBeGreaterThanOrEqual( + model.frontmatter.block!.end + ); + }); +}); + +const text_of = (doc: string, node: SectionNode): string => + doc.slice(node.body.start, node.body.end); + +// A small deterministic PRNG so failures are reproducible from their seed. +const mulberry32 = (seed: number): (() => number) => { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; + +const randomDocument = (rand: () => number): string => { + const eol = rand() < 0.3 ? "\r\n" : "\n"; + const pick = (xs: T[]): T => xs[Math.floor(rand() * xs.length)]; + const parts: string[] = []; + if (rand() < 0.4) { + parts.push(`---${eol}title: t${eol}count: ${Math.floor(rand() * 9)}${eol}---${eol}`); + } + const blockCount = Math.floor(rand() * 8); + for (let i = 0; i < blockCount; i++) { + const kind = pick(["heading", "para", "blank", "list", "block-id"]); + if (kind === "heading") { + const level = 1 + Math.floor(rand() * 6); + const text = rand() < 0.15 ? "" : ` H${i}`; + parts.push(`${"#".repeat(level)}${text}${eol}`); + } else if (kind === "para") { + parts.push(`paragraph ${i}${eol}`); + } else if (kind === "blank") { + parts.push(eol); + } else if (kind === "list") { + parts.push(`- item ${i}a${eol}- item ${i}b${eol}`); + } else if (kind === "block-id") { + parts.push(`paragraph ${i} ^b${i}${eol}${eol}`); + } + } + let doc = parts.join(""); + if (rand() < 0.2 && doc.endsWith(eol)) { + doc = doc.slice(0, -eol.length); // sometimes drop the final newline + } + return doc; +}; + +describe("model partition fuzz", () => { + test("round-trips across 500 randomly generated documents", () => { + for (let seed = 1; seed <= 500; seed++) { + const rand = mulberry32(seed); + const doc = randomDocument(rand); + const model = buildModel(doc); + expect({ seed, out: serializeModel(doc, model) }).toEqual({ + seed, + out: doc, + }); + // Section ranges must tile: each section's trailingGap end meets the + // next boundary and gaps are whitespace only. + eachSection(model.root, (node) => { + expect(node.body.end).toEqual(node.trailingGap.start); + expect(doc.slice(node.trailingGap.start, node.trailingGap.end)).toMatch( + /^\s*$/ + ); + }); + } + }); +}); diff --git a/src/tests/patch.test.ts b/src/tests/patch.test.ts deleted file mode 100644 index 5dd2683..0000000 --- a/src/tests/patch.test.ts +++ /dev/null @@ -1,1395 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; - -import { FrontmatterPatchInstruction, PatchInstruction } from "../types"; -import { applyPatch, PatchError, PatchFailed } from "../patch"; -import { FrontmatterParseError } from "../map"; -import { ContentType } from "../types"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("patch", () => { - const sample = fs.readFileSync(path.join(__dirname, "sample.md"), "utf-8"); - const sampleFrontmatter = fs.readFileSync( - path.join(__dirname, "sample.frontmatter.md"), - "utf-8" - ); - - const assertPatchResultsMatch = ( - inputDocumentPath: string, - outputDocumentPath: string, - instruction: PatchInstruction - ) => { - const inputDocument = fs.readFileSync( - path.join(__dirname, inputDocumentPath), - "utf-8" - ); - const outputDocument = fs.readFileSync( - path.join(__dirname, outputDocumentPath), - "utf-8" - ); - - expect(applyPatch(inputDocument, instruction)).toEqual(outputDocument); - }; - - describe("heading", () => { - test("prepend", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Overview"], - operation: "prepend", - content: "Beep Boop\n", - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.prepend.md", - instruction - ); - }); - test("append", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Overview"], - operation: "append", - content: "Beep Boop\n", - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.append.md", - instruction - ); - }); - test("replace", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Overview"], - operation: "replace", - content: "Beep Boop\n", - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.replace.md", - instruction - ); - }); - describe("document", () => { - test("prepend", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: null, - operation: "prepend", - content: "Beep Boop\n", - }; - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.document.prepend.md", - instruction - ); - }); - test("append", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: null, - operation: "append", - content: "Beep Boop\n", - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.document.append.md", - instruction - ); - }); - }); - }); - - describe("parameter", () => { - describe("trimTargetWhitespace", () => { - describe("heading", () => { - test("prepend", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Page Targets", "Document Properties (Exploratory)"], - operation: "prepend", - content: "Beep Boop", - trimTargetWhitespace: true, - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.trimTargetWhitespace.prepend.md", - instruction - ); - }); - test("append", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Problems"], - operation: "append", - content: "Beep Boop\n", - trimTargetWhitespace: true, - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.trimTargetWhitespace.append.md", - instruction - ); - }); - }); - }); - - describe("createTargetIfMissing", () => { - test("nested", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Page Targets", "Block", "Test"], - operation: "replace", - content: "Beep Boop\n", - createTargetIfMissing: true, - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.createIfMissing.nested.md", - instruction - ); - }); - - test("root", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Alpha", "Beta", "Test"], - operation: "replace", - content: "Beep Boop\n", - createTargetIfMissing: true, - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.heading.createIfMissing.root.md", - instruction - ); - }); - }); - - describe("rejectIfContentPreexists", () => { - describe("disabled (default)", () => { - describe("heading", () => { - test("preexists at target", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Page Targets"], - operation: "append", - content: "## Frontmatter Field", - // rejectIfContentPreexists: false, # default - }; - - expect(() => { - applyPatch(sample, instruction); - }).not.toThrow(PatchFailed); - }); - test("does not preexist at target", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Headers"], - operation: "append", - content: "## Frontmatter Field", - // rejectIfContentPreexists: false, # default - }; - - expect(() => { - applyPatch(sample, instruction); - }).not.toThrow(PatchFailed); - }); - }); - describe("block (table)", () => { - const tableDoc = - "| A | B |\n| --- | --- |\n| x | y |\n| p | q |\n\n^tbl1\n"; - test("append: all rows preexist — still proceeds", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "append", - contentType: ContentType.json, - content: [["x", "y"]], - // rejectIfContentPreexists: false, # default - }; - expect(() => applyPatch(tableDoc, instruction)).not.toThrow( - PatchFailed - ); - }); - test("prepend: all rows preexist — still proceeds", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "prepend", - contentType: ContentType.json, - content: [["x", "y"]], - // rejectIfContentPreexists: false, # default - }; - expect(() => applyPatch(tableDoc, instruction)).not.toThrow( - PatchFailed - ); - }); - }); - }); - describe("enabled", () => { - describe("heading", () => { - test("preexists at target", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Page Targets"], - operation: "append", - content: "## Frontmatter Field", - rejectIfContentPreexists: true, - }; - - expect(() => { - applyPatch(sample, instruction); - }).toThrow(PatchFailed); - }); - test("does not preexist at target", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Headers"], - operation: "append", - content: "## Frontmatter Field", - rejectIfContentPreexists: true, - }; - - expect(() => { - applyPatch(sample, instruction); - }).not.toThrow(PatchFailed); - }); - }); - describe("block (table)", () => { - const tableDoc = - "| A | B |\n| --- | --- |\n| x | y |\n| p | q |\n\n^tbl1\n"; - test("append: all rows preexist — throws", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "append", - contentType: ContentType.json, - content: [["x", "y"]], - rejectIfContentPreexists: true, - }; - expect(() => applyPatch(tableDoc, instruction)).toThrow(PatchFailed); - }); - test("append: multiple rows all preexist — throws", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "append", - contentType: ContentType.json, - content: [ - ["x", "y"], - ["p", "q"], - ], - rejectIfContentPreexists: true, - }; - expect(() => applyPatch(tableDoc, instruction)).toThrow(PatchFailed); - }); - test("append: at least one new row — proceeds", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "append", - contentType: ContentType.json, - content: [ - ["x", "y"], - ["z", "w"], - ], - rejectIfContentPreexists: true, - }; - expect(() => applyPatch(tableDoc, instruction)).not.toThrow( - PatchFailed - ); - }); - test("append: no rows preexist — proceeds", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "append", - contentType: ContentType.json, - content: [["z", "w"]], - rejectIfContentPreexists: true, - }; - expect(() => applyPatch(tableDoc, instruction)).not.toThrow( - PatchFailed - ); - }); - test("prepend: all rows preexist — throws", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "prepend", - contentType: ContentType.json, - content: [["p", "q"]], - rejectIfContentPreexists: true, - }; - expect(() => applyPatch(tableDoc, instruction)).toThrow(PatchFailed); - }); - test("prepend: at least one new row — proceeds", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "tbl1", - operation: "prepend", - contentType: ContentType.json, - content: [ - ["p", "q"], - ["z", "w"], - ], - rejectIfContentPreexists: true, - }; - expect(() => applyPatch(tableDoc, instruction)).not.toThrow( - PatchFailed - ); - }); - }); - }); - describe("never applies to replace", () => { - test("replace always applies even when incoming content is a subset of existing", () => { - const doc = "## Section\n\nfoo bar baz\n\n## Next\n\ncontent\n"; - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Section"], - operation: "replace", - content: "bar baz", - }; - expect(() => applyPatch(doc, instruction)).not.toThrow(); - }); - }); - }); - }); - describe("block", () => { - test("prepend", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "e6068e", - operation: "prepend", - content: "- OK\n", - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.prepend.md", - instruction - ); - }); - test("append", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "e6068e", - operation: "append", - content: "\n- OK", - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.append.md", - instruction - ); - }); - test("replace", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "259a73", - operation: "replace", - content: "- OK", - }; - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.replace.md", - instruction - ); - }); - describe("regression: issue #5 - blank line between content and block ID removed (table/list blocks)", () => { - test("Bug: Replace table block - blank line should be preserved", () => { - const original = - "| Col A | Col B |\n| --- | --- |\n| 1 | 2 |\n\n^table1\n"; - const replacement = "| X | Y |\n| --- | --- |\n| 9 | 8 |"; - - const instruction: PatchInstruction = { - operation: "replace", - targetType: "block", - target: "table1", - contentType: ContentType.text, - content: replacement, - }; - - const result = applyPatch(original, instruction); - const lines = result.split("\n"); - const blockIdLine = lines.findIndex((line) => line === "^table1"); - expect(lines[blockIdLine - 1]).toEqual(""); - }); - test("Bug: Replace list block - blank line should be preserved", () => { - const original = "- Item A\n- Item B\n- Item C\n\n^list1\n"; - const replacement = "- New 1\n- New 2"; - - const instruction: PatchInstruction = { - operation: "replace", - targetType: "block", - target: "list1", - contentType: ContentType.text, - content: replacement, - }; - - const result = applyPatch(original, instruction); - expect(result).toEqual("- New 1\n- New 2\n\n^list1\n"); - }); - }); - describe("regression: issue #4 - trailing bytes from old content leak into replacement (list blocks)", () => { - test("replace (isolated block ref, shorter replacement - no trailing byte leak)", () => { - const original = "- Item 1\n- Item 2\n- Item 3\n\n^list1\n"; - const instruction: PatchInstruction = { - targetType: "block", - target: "list1", - operation: "replace", - contentType: ContentType.text, - content: "- New A\n- New B", - }; - expect(applyPatch(original, instruction)).toEqual( - "- New A\n- New B\n\n^list1\n" - ); - }); - test("replace (isolated block ref, multibyte UTF-8 - no trailing byte leak)", () => { - const original = "- 項目1\n- 項目2\n- 項目3\n\n^list1\n"; - const instruction: PatchInstruction = { - targetType: "block", - target: "list1", - operation: "replace", - contentType: ContentType.text, - content: "- 新項目A\n- 新項目B", - }; - expect(applyPatch(original, instruction)).toEqual( - "- 新項目A\n- 新項目B\n\n^list1\n" - ); - }); - }); - describe("regression: issue #6 - entire document duplicated at end of file (quote blocks)", () => { - test("Bug: Replace quote block - document should not be duplicated", () => { - const original = - "# Test\n\n" + - "Paragraph ^inline1\n\n" + - "> Quote line 1\n" + - "> Quote line 2\n\n" + - "^quote1\n\n" + - "[[some link]] paragraph ^wikilink1\n"; - - const replacement = "> New quote"; - - const instruction: PatchInstruction = { - operation: "replace", - targetType: "block", - target: "quote1", - contentType: ContentType.text, - content: replacement, - }; - - const result = applyPatch(original, instruction); - - // Check for duplication - const headingCount = (result.match(/# Test/g) || []).length; - expect(headingCount).toBe(1); - - const linkCount = (result.match(/\[\[some link\]\]/g) || []).length; - expect(linkCount).toBe(1); - }); - }); - describe("regression: issue #7 - heading append/replace consumes trailing blank line, corrupting subsequent heading targets", () => { - // Core bug cases - test("append: blank line before next section is preserved", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item", - }; - const result = applyPatch(original, instruction); - expect(result).toEqual( - "## Section A\n\nContent A.\n\n- new item\n\n## Section B\n\nContent B.\n" - ); - }); - test("append: Section B is still targetable after patching Section A", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const patched = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item", - }); - expect(() => - applyPatch(patched, { - targetType: "heading", - target: ["Section B"], - operation: "append", - content: "- another item", - }) - ).not.toThrow(); - }); - test("replace: blank line before next section is preserved", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Section A"], - operation: "replace", - content: "New content", - }; - const result = applyPatch(original, instruction); - expect(result).toEqual( - "## Section A\nNew content\n\n## Section B\n\nContent B.\n" - ); - }); - - // No double-adding when content already ends with \n\n - test("append: content already ending with \\n\\n does not get extra blank line", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item\n\n", - }); - expect(result).toEqual( - "## Section A\n\nContent A.\n\n- new item\n\n## Section B\n\nContent B.\n" - ); - }); - test("append: content ending with exactly \\n gets one more \\n (not two)", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item\n", - }); - expect(result).toEqual( - "## Section A\n\nContent A.\n\n- new item\n\n## Section B\n\nContent B.\n" - ); - }); - test("replace: content already ending with \\n\\n does not get extra blank line", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "replace", - content: "New content\n\n", - }); - expect(result).toEqual( - "## Section A\nNew content\n\n## Section B\n\nContent B.\n" - ); - }); - test("replace: content ending with \\n gets one more \\n", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "replace", - content: "New content\n", - }); - expect(result).toEqual( - "## Section A\nNew content\n\n## Section B\n\nContent B.\n" - ); - }); - - // Last section — no separator needed - test("append: no separator added when section is last in document", () => { - const original = "## Section A\n\nContent A.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item", - }); - expect(result).toEqual("## Section A\n\nContent A.\n- new item"); - }); - - // No blank line between headings — fix must not add one - test("append: no blank line added when sections are not blank-line separated", () => { - const original = "## Section A\nContent A.\n## Section B\nContent B.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item", - }); - expect(result).toEqual( - "## Section A\nContent A.\n- new item## Section B\nContent B.\n" - ); - }); - - // trimTargetWhitespace suppresses the separator restoration - test("append with trimTargetWhitespace: separator not re-added after trimmed content", () => { - const original = - "## Section A\n\nContent A.\n\n## Section B\n\nContent B.\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item", - trimTargetWhitespace: true, - }); - // trimTargetWhitespace strips trailing whitespace from the section; - // the caller took responsibility for whitespace, so no \n\n is restored. - expect(result).toEqual( - "## Section A\n\nContent A.- new item## Section B\n\nContent B.\n" - ); - }); - }); - describe("regression: issue #231 - append under nested heading inserts extra blank line before content", () => { - test("append to last nested section: no extra blank line when document ends with trailing blank line", () => { - // Reproduces the exact report: '# Tasks\n## Stretch\n- item1\n- item2\n\n' - // (Obsidian commonly saves files with a trailing blank line.) - // Appending '- [ ] new task' should follow immediately after the last - // list item — no double-newline gap before it. - const original = - "# Tasks\n## Stretch\n- Existing task 1\n- Existing task 2\n\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Tasks", "Stretch"], - operation: "append", - content: "- [ ] new task", - }); - expect(result).not.toContain("\n\n- [ ] new task"); - expect(result).toEqual( - "# Tasks\n## Stretch\n- Existing task 1\n- Existing task 2\n- [ ] new task" - ); - }); - test("append to flat last section: no extra blank line when document ends with trailing blank line", () => { - const original = "## Section A\n\nContent A.\n\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Section A"], - operation: "append", - content: "- new item", - }); - expect(result).not.toContain("\n\n- new item"); - expect(result).toEqual("## Section A\n\nContent A.\n- new item"); - }); - }); - describe("regression: issue #10 - heading boundary detection treats code-span heading inside table cell as a section boundary", () => { - const original = - "---\ntitle: fixture-table-codespan\n---\n\n" + - "## Journal\n\n" + - "| Date | Event |\n" + - "| ---------- | -------------------------------------------------------------------------------------- |\n" + - "| 2026-01-01 | Initial entry. Discusses the `## Links` section below. |\n\n" + - "## Links\n\n" + - "- Parent\n"; - - test("replace: section body is fully replaced without corrupting the table", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Journal"], - operation: "replace", - content: - "| Date | Event |\n| --- | --- |\n| 2026-01-02 | Updated entry |\n", - }; - const result = applyPatch(original, instruction); - expect(result).toEqual( - "---\ntitle: fixture-table-codespan\n---\n\n" + - "## Journal\n" + - "| Date | Event |\n| --- | --- |\n| 2026-01-02 | Updated entry |\n\n" + - "## Links\n\n" + - "- Parent\n" - ); - }); - - test("replace: Links section is still correctly targetable after patching Journal", () => { - const first = applyPatch(original, { - targetType: "heading", - target: ["Journal"], - operation: "replace", - content: - "| Date | Event |\n| --- | --- |\n| 2026-01-02 | Updated entry |\n", - }); - const second = applyPatch(first, { - targetType: "heading", - target: ["Links"], - operation: "replace", - content: "- Child\n", - }); - expect(second).toEqual( - "---\ntitle: fixture-table-codespan\n---\n\n" + - "## Journal\n" + - "| Date | Event |\n| --- | --- |\n| 2026-01-02 | Updated entry |\n\n" + - "## Links\n" + - "- Child\n" - ); - }); - }); - describe("regression: issue #10 variants - boundary false-match on heading-like text in section body", () => { - // Variant B: code-span heading ref in table cell, nested target path - test("replace nested target: code-span in table cell does not corrupt section", () => { - const original = - "## Root\n\n" + - "### Journal\n\n" + - "| Date | Event |\n" + - "| --- | --- |\n" + - "| 2026-01-01 | See the `## Sibling` section. |\n\n" + - "## Sibling\n\n" + - "- Item\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Root", "Journal"], - operation: "replace", - content: "REPLACEMENT.\n", - }); - expect(result).toEqual( - "## Root\n\n" + - "### Journal\n" + - "REPLACEMENT.\n\n" + - "## Sibling\n\n" + - "- Item\n" - ); - }); - - // Variant C: bare `## heading` substring in plain prose (no table, no code-span) - test("replace: bare heading substring in prose does not become a section boundary", () => { - const original = - "## Journal\n\n" + - "Original content. This refers to ## Heading below.\n\n" + - "## Heading\n\n" + - "- Item\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Journal"], - operation: "replace", - content: "REPLACEMENT.\n", - }); - expect(result).toEqual( - "## Journal\n" + - "REPLACEMENT.\n\n" + - "## Heading\n\n" + - "- Item\n" - ); - }); - - // Variant D: code-span heading ref in plain paragraph (no table) - test("replace: code-span heading ref in paragraph does not become a section boundary", () => { - const original = - "## Journal\n\n" + - "Original content. See the `## Heading` section below.\n\n" + - "## Heading\n\n" + - "- Item\n"; - const result = applyPatch(original, { - targetType: "heading", - target: ["Journal"], - operation: "replace", - content: "REPLACEMENT.\n", - }); - expect(result).toEqual( - "## Journal\n" + - "REPLACEMENT.\n\n" + - "## Heading\n\n" + - "- Item\n" - ); - }); - }); - describe("tagetBlockTypeBehavior", () => { - describe("table (multiple)", () => { - test("prepend", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "2c67a6", - operation: "prepend", - contentType: ContentType.json, - content: [ - ["`something else`", "Some other application", "✅", "✅", "✅"], - ], - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.targetBlockTypeBehavior.table.prepend.md", - instruction - ); - }); - test("append", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "2c67a6", - operation: "append", - contentType: ContentType.json, - content: [ - ["`something else`", "Some other application", "✅", "✅", "✅"], - ], - }; - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.targetBlockTypeBehavior.table.append.md", - instruction - ); - }); - test("replace", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "2c67a6", - operation: "replace", - contentType: ContentType.json, - content: [ - ["`something else`", "Some other application", "✅", "✅", "✅"], - ], - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.targetBlockTypeBehavior.table.replace.md", - instruction - ); - }); - }); - describe("table (single)", () => { - test("prepend", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "2c67a6", - operation: "prepend", - contentType: ContentType.json, - content: [ - "`something else`", - "Some other application", - "✅", - "✅", - "✅", - ], - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.targetBlockTypeBehavior.table.prepend.md", - instruction - ); - }); - test("append", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "2c67a6", - operation: "append", - contentType: ContentType.json, - content: [ - "`something else`", - "Some other application", - "✅", - "✅", - "✅", - ], - }; - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.targetBlockTypeBehavior.table.append.md", - instruction - ); - }); - test("replace", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "2c67a6", - operation: "replace", - contentType: ContentType.json, - content: [ - "`something else`", - "Some other application", - "✅", - "✅", - "✅", - ], - }; - - assertPatchResultsMatch( - "sample.md", - "sample.patch.block.targetBlockTypeBehavior.table.replace.md", - instruction - ); - }); - }); - }); - }); - describe("targetScope", () => { - describe("heading", () => { - test("replace: targetScope:markerAndContent replaces heading line along with content", () => { - const doc = "# Old Title\nSome content here.\n# Next\nOther.\n"; - const result = applyPatch(doc, { - targetType: "heading", - target: ["Old Title"], - operation: "replace", - targetScope: "markerAndContent", - content: "# New Title\nNew content.\n", - }); - expect(result).toEqual("# New Title\nNew content.\n# Next\nOther.\n"); - }); - - test("prepend: targetScope:markerAndContent inserts before heading line", () => { - const doc = "# Section\nContent here.\n# Next\nOther.\n"; - const result = applyPatch(doc, { - targetType: "heading", - target: ["Section"], - operation: "prepend", - targetScope: "markerAndContent", - content: "---\n", - }); - expect(result).toEqual("---\n# Section\nContent here.\n# Next\nOther.\n"); - }); - }); - - describe("block", () => { - test("replace: targetScope:markerAndContent removes block ID marker", () => { - const doc = "Some text. ^my-block\n\nNext.\n"; - const result = applyPatch(doc, { - targetType: "block", - target: "my-block", - operation: "replace", - targetScope: "markerAndContent", - content: "New text.", - }); - expect(result).toEqual("New text.\n\nNext.\n"); - }); - }); - - describe("targetScope:marker", () => { - test("heading replace: renames heading line without touching content", () => { - const doc = "# Old Title\nSome content here.\n# Next\nOther.\n"; - const result = applyPatch(doc, { - targetType: "heading", - target: ["Old Title"], - operation: "replace", - targetScope: "marker", - content: "# New Title\n", - }); - expect(result).toEqual("# New Title\nSome content here.\n# Next\nOther.\n"); - }); - - test("block replace: renames block ID without touching content", () => { - const doc = "Some text. ^old-id\n\nNext.\n"; - const result = applyPatch(doc, { - targetType: "block", - target: "old-id", - operation: "replace", - targetScope: "marker", - content: " ^new-id", - }); - expect(result).toEqual("Some text. ^new-id\n\nNext.\n"); - }); - }); - }); - - describe("regression: issue #11 - strict YAML parse failure in frontmatter blocks body-only patches", () => { - const colonInFrontmatter = fs.readFileSync( - path.join(__dirname, "sample.frontmatter.colon-in-value.md"), - "utf-8" - ); - - test("block replace throws FrontmatterParseError when frontmatter contains an unescaped colon in a scalar value", () => { - const instruction: PatchInstruction = { - targetType: "block", - target: "block-1", - operation: "replace", - content: "New content.", - }; - expect(() => applyPatch(colonInFrontmatter, instruction)).toThrow(FrontmatterParseError); - }); - - test("heading replace throws FrontmatterParseError when frontmatter contains an unescaped colon in a scalar value", () => { - const instruction: PatchInstruction = { - targetType: "heading", - target: ["Body"], - operation: "replace", - content: "New content.\n", - }; - expect(() => applyPatch(colonInFrontmatter, instruction)).toThrow(FrontmatterParseError); - }); - }); - - describe("frontmatter", () => { - describe("append", () => { - test("mismatched types", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "append", - contentType: ContentType.json, - content: "OK", - }; - - expect(() => { - applyPatch(sample, instruction); - }).toThrow(PatchFailed); - }); - test("invalid type", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "append", - contentType: ContentType.json, - content: 10, - }; - - expect(() => { - applyPatch(sample, instruction); - }).toThrow(PatchFailed); - }); - test("type mismatch in existing field throws PatchFailed", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "append", - contentType: ContentType.json, - content: "OK", - }; - - expect(() => { - applyPatch(sampleFrontmatter, instruction); - }).toThrow(PatchFailed); - }); - test("list", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "append", - contentType: ContentType.json, - content: ["Beep"], - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.append.list.md", - instruction - ); - }); - test("dictionary", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "object-value", - operation: "append", - contentType: ContentType.json, - content: { three: "Beep" }, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.append.dictionary.md", - instruction - ); - }); - test("string", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "string-value", - operation: "append", - contentType: ContentType.json, - content: "Beep", - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.append.string.md", - instruction - ); - }); - }); - describe("prepend", () => { - test("mismatched types", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "prepend", - contentType: ContentType.json, - content: "OK", - }; - - expect(() => { - applyPatch(sample, instruction); - }).toThrow(PatchFailed); - }); - test("invalid type", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "prepend", - contentType: ContentType.json, - content: 10, - }; - - expect(() => { - applyPatch(sample, instruction); - }).toThrow(PatchFailed); - }); - test("type mismatch in existing field throws PatchFailed", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "prepend", - contentType: ContentType.json, - content: "OK", - }; - - expect(() => { - applyPatch(sampleFrontmatter, instruction); - }).toThrow(PatchFailed); - }); - test("list", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "prepend", - contentType: ContentType.json, - content: ["Beep"], - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.prepend.list.md", - instruction - ); - }); - test("dictionary", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "object-value", - operation: "prepend", - contentType: ContentType.json, - content: { three: "Beep" }, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.prepend.dictionary.md", - instruction - ); - }); - test("string", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "string-value", - operation: "prepend", - contentType: ContentType.json, - content: "Beep", - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.prepend.string.md", - instruction - ); - }); - }); - describe("replace", () => { - test("list", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "array-value", - operation: "replace", - contentType: ContentType.json, - content: ["Replaced"], - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.replace.list.md", - instruction - ); - }); - test("dictionary", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "object-value", - operation: "replace", - contentType: ContentType.json, - content: { - replaced: true, - }, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.replace.dictionary.md", - instruction - ); - }); - test("string", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "string-value", - operation: "replace", - contentType: ContentType.json, - content: "Replaced", - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.replace.string.md", - instruction - ); - }); - test("number", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "number-value", - operation: "replace", - contentType: ContentType.json, - content: 10, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.replace.number.md", - instruction - ); - }); - test("boolean", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "boolean-value", - operation: "replace", - contentType: ContentType.json, - content: true, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.replace.boolean.md", - instruction - ); - }); - describe("createTargetIfMissing", () => { - - test("works when the existing frontmatter block is empty", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "title", - operation: "replace", - contentType: ContentType.json, - content: "T", - createTargetIfMissing: true, - }; - - expect(applyPatch("---\n---\n# H\nbody\n", instruction)).toEqual( - "---\ntitle: T\n---\n# H\nbody\n" - ); - }); - - test("list", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "new-field", - operation: "replace", - contentType: ContentType.json, - content: ["New Value"], - createTargetIfMissing: true, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.createTargetIfMissing.list.md", - instruction - ); - }); - test("dictionary", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "new-field", - operation: "replace", - contentType: ContentType.json, - content: { - newValue: true, - }, - createTargetIfMissing: true, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.createTargetIfMissing.dictionary.md", - instruction - ); - }); - test("string", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "new-field", - operation: "replace", - contentType: ContentType.json, - content: "New Value", - createTargetIfMissing: true, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.createTargetIfMissing.string.md", - instruction - ); - }); - test("number", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "new-field", - operation: "replace", - contentType: ContentType.json, - content: 588600, - createTargetIfMissing: true, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.createTargetIfMissing.number.md", - instruction - ); - }); - test("boolean", () => { - const instruction: FrontmatterPatchInstruction = { - targetType: "frontmatter", - target: "new-field", - operation: "replace", - contentType: ContentType.json, - content: true, - createTargetIfMissing: true, - }; - assertPatchResultsMatch( - "sample.frontmatter.md", - "sample.patch.frontmatter.createTargetIfMissing.boolean.md", - instruction - ); - }); - }); - }); - }); - - describe("unknown contentType", () => { - const doc = "## Overview\n\nSome content.\n"; - const unknownContentType = "text/xml" as unknown as ContentType; - - test("replace throws for unrecognised contentType", () => { - const instruction = { - targetType: "heading", - target: ["Overview"], - operation: "replace", - content: "New content\n", - contentType: unknownContentType, - } as unknown as PatchInstruction; - - expect(() => applyPatch(doc, instruction)).toThrow(PatchError); - }); - - test("prepend throws for unrecognised contentType", () => { - const instruction = { - targetType: "heading", - target: ["Overview"], - operation: "prepend", - content: "New content\n", - contentType: unknownContentType, - } as unknown as PatchInstruction; - - expect(() => applyPatch(doc, instruction)).toThrow(PatchError); - }); - - test("append throws for unrecognised contentType", () => { - const instruction = { - targetType: "heading", - target: ["Overview"], - operation: "append", - content: "New content\n", - contentType: unknownContentType, - } as unknown as PatchInstruction; - - expect(() => applyPatch(doc, instruction)).toThrow(PatchError); - }); - }); -}); diff --git a/src/tests/projection.test.ts b/src/tests/projection.test.ts new file mode 100644 index 0000000..9dfec0f --- /dev/null +++ b/src/tests/projection.test.ts @@ -0,0 +1,211 @@ +import { buildModel, eachSection } from "../model"; +import { projectMap, headingTreePaths, headingPath } from "../projection"; +import { resolveHeading } from "../resolve"; + +describe("projectMap", () => { + test("produces the first-pass public shape", () => { + const doc = + "---\n" + + "status: draft\n" + + "reviewers:\n" + + "- alice\n" + + "---\n\n" + + "# Overview\n\n" + + "The thesis. ^thesis\n\n" + + "### Known quirks\n\n" + + "| quirk | fixed |\n| --- | --- |\n| a | b |\n\n^quirks\n\n" + + "# Development Logs\n\n" + + "## 2026-07-18\n\nfirst\n\n" + + "## 2026-07-18\n\nsecond\n"; + const map = projectMap(buildModel(doc)); + + expect(map.frontmatterFields).toEqual(["status", "reviewers"]); + // Headings nest by containment (the skipped h2 leaves no hole), and the + // second "2026-07-18" gets its own disambiguated key. + expect(map.headings).toEqual({ + Overview: { "Known quirks": {} }, + "Development Logs": { + "2026-07-18": {}, + "2026-07-18\u{FC750}\u{F6440}": {}, + }, + }); + expect(map.blocks).toEqual(["thesis", "quirks"]); + expect(map.version).toMatch(/^[0-9a-f]{6}$/); + }); + + test("skipped levels nest by containment and empty heading text is a key", () => { + const doc = "# \n\nbody\n\n#### Deep\n\ndeep\n"; + const map = projectMap(buildModel(doc)); + expect(map.headings).toEqual({ "": { Deep: {} } }); + }); + + test("version tracks content and matches the model", () => { + const a = buildModel("# A\n\nbody\n"); + const b = buildModel("# A\n\nbody changed\n"); + expect(projectMap(a).version).toEqual(a.version); + expect(projectMap(a).version).not.toEqual(projectMap(b).version); + }); + + test("headings and blocks are empty for a bare document", () => { + const map = projectMap(buildModel("just text, no structure\n")); + expect(map.headings).toEqual({}); + expect(map.blocks).toEqual([]); + expect(map.frontmatterFields).toEqual([]); + }); + + test("a repeated sibling name gets a distinct key per occurrence", () => { + const doc = + "## Log\n\n### Monday\n\nm\n\n## Log\n\n### Tuesday\n\nt\n"; + const map = projectMap(buildModel(doc)); + // The first "Log" keeps its plain text; the second gets a marker suffix + // so both are separately addressable, each with its own subtree. + const secondLog = "Log\u{FC750}\u{F6440}"; + expect(map.headings).toEqual({ + Log: { Monday: {} }, + [secondLog]: { Tuesday: {} }, + }); + }); + + test("sections that would have collided on path alone now get distinct addresses", () => { + const doc = + "## Log\n\n### Monday\n\nfirst\n\n## Log\n\n### Monday\n\nsecond\n"; + const map = projectMap(buildModel(doc)); + // Both "Log"s have a "Monday" child, but the second "Log" is itself + // disambiguated, so the two Mondays end up on distinct paths. + const secondLog = "Log\u{FC750}\u{F6440}"; + expect(map.headings).toEqual({ + Log: { Monday: {} }, + [secondLog]: { Monday: {} }, + }); + }); + + test("a repeat's descendants nest under the repeat's own disambiguated key", () => { + const doc = + "# A\n\n## X\n\nfirst\n\n# A\n\n## X\n\n### Z\n\nz\n"; + const map = projectMap(buildModel(doc)); + const secondA = "A\u{FC750}\u{F6440}"; + expect(map.headings).toEqual({ + A: { X: {} }, + [secondA]: { X: { Z: {} } }, + }); + }); + + test("a block under a repeated heading is still listed, and each heading gets its own key", () => { + const doc = + "## Log\n\nfirst ^a\n\n## Log\n\nsecond ^b\n"; + const map = projectMap(buildModel(doc)); + // Blocks are addressed globally and both stay listed regardless. + const secondLog = "Log\u{FC750}\u{F6440}"; + expect(map.headings).toEqual({ Log: {}, [secondLog]: {} }); + expect(map.blocks).toEqual(["a", "b"]); + }); + + test("a third occurrence advances the hex digit", () => { + const doc = "## Dup\n\na\n\n## Dup\n\nb\n\n## Dup\n\nc\n"; + const map = projectMap(buildModel(doc)); + expect(Object.keys(map.headings)).toEqual([ + "Dup", + "Dup\u{FC750}\u{F6440}", + "Dup\u{FC750}\u{F6441}", + ]); + }); + + test("a duplicate block id gets its own disambiguated entry in the blocks list", () => { + const doc = ["first ^dup", "", "second ^dup", "", "third ^dup", ""].join("\n"); + const map = projectMap(buildModel(doc)); + expect(map.blocks).toEqual([ + "dup", + "dup\u{FC750}\u{F6440}", + "dup\u{FC750}\u{F6441}", + ]); + }); + + test("occurrence indexes past 16 cross into two hex digits", () => { + const lines: string[] = []; + for (let i = 0; i < 18; i++) { + lines.push("## Dup", "", `body ${i}`, ""); + } + const map = projectMap(buildModel(lines.join("\n"))); + const keys = Object.keys(map.headings); + expect(keys).toHaveLength(18); + // The 17th occurrence (index 15, hex "f") is the last representable in + // a single reserved digit. + expect(keys[16]).toBe("Dup\u{FC750}\u{F644F}"); + // The 18th occurrence (index 16, hex "10") is the first that needs two. + expect(keys[17]).toBe("Dup\u{FC750}\u{F6441}\u{F6440}"); + }); + + test("a heading literally named __proto__ is a real, addressable key", () => { + const doc = "# __proto__\n\nbody\n\n## Child\n\nc\n"; + const map = projectMap(buildModel(doc)); + // A plain `into[text] = subtree` assignment for text === "__proto__" sets + // the object's prototype instead of an own property, silently dropping the + // heading from the map. It must come back as a real own, enumerable key. + expect(Object.prototype.hasOwnProperty.call(map.headings, "__proto__")).toBe( + true + ); + expect(map.headings.__proto__).toEqual({ Child: {} }); + expect(Object.keys(map.headings)).toEqual(["__proto__"]); + }); + + test("headingTreePaths enumerates every address in document order", () => { + const paths = headingTreePaths( + projectMap(buildModel("# A\n\n## B\n\nb\n\n### C\n\nc\n\n# D\n\nd\n")).headings + ); + expect(paths).toEqual([["A"], ["A", "B"], ["A", "B", "C"], ["D"]]); + }); +}); + +// The map's contract: it advertises neither more nor less than the resolver +// accepts. Under-reporting hides reachable headings from a consumer whose only +// view of the document is this map; over-reporting hands out addresses that 404. +describe("map/resolver agreement — the tree is exactly the addressable set", () => { + const documents: Array<{ name: string; document: string }> = [ + { name: "plain nesting", document: "# A\n\n## B\n\nb\n\n### C\n\nc\n\n# D\n\nd\n" }, + { name: "skipped levels", document: "# A\n\nbody\n\n#### Deep\n\ndeep\n" }, + { + name: "repeated sibling with distinct children", + document: "## Log\n\n### Monday\n\nm\n\n## Log\n\n### Tuesday\n\nt\n", + }, + { + name: "repeated sibling with colliding children", + document: "## Log\n\n### Monday\n\nfirst\n\n## Log\n\n### Monday\n\nsecond\n", + }, + { + name: "repeat nested below a shared path", + document: "# A\n\n## X\n\nfirst\n\n# A\n\n## X\n\n### Z\n\nz\n", + }, + { + name: "three siblings with the same text", + document: "## Dup\n\na\n\n## Dup\n\nb\n\n## Dup\n\nc\n", + }, + { name: "empty heading text", document: "# \n\nbody\n\n## Child\n\nc\n" }, + { name: "no headings at all", document: "just prose\n" }, + { + name: "__proto__ as heading text", + document: "# __proto__\n\nbody\n\n## Child\n\nc\n", + }, + ]; + + test.each(documents)("$name", ({ document }) => { + const model = buildModel(document); + const advertised = headingTreePaths(projectMap(model).headings); + + // Every advertised address resolves. + for (const address of advertised) { + expect(resolveHeading(model, address)).not.toBeNull(); + } + + // ...and every heading in the document is advertised, so nothing reachable + // is hidden. Every section now gets its own disambiguated address, so the + // advertised and actual sets should match one-for-one, with no dedup. + const actual: string[][] = []; + eachSection(model.root, (node) => { + if (node.heading) { + actual.push(headingPath(node)); + } + }); + const key = (path: string[]): string => JSON.stringify(path); + expect(new Set(advertised.map(key))).toEqual(new Set(actual.map(key))); + }); +}); diff --git a/src/tests/read.test.ts b/src/tests/read.test.ts new file mode 100644 index 0000000..82f0dfd --- /dev/null +++ b/src/tests/read.test.ts @@ -0,0 +1,292 @@ +import { readTarget } from "../read"; +import { patch } from "../engine"; +import { InvalidInstructionError, TargetNotFoundError } from "../instructions"; + +const DOC = + "---\n" + + "title: Draft\n" + + "tags:\n" + + "- a\n" + + "- b\n" + + "---\n\n" + + "# Overview\n\n" + + "The thesis. ^thesis\n\n" + + "## Details\n\n" + + "Nested body.\n\n" + + "# Other\n\n" + + "Elsewhere.\n"; + +describe("readTarget", () => { + test("a heading yields its whole section body, subsections included", () => { + const result = readTarget(DOC, { targetType: "heading", target: ["Overview"] }); + expect(result.kind).toBe("heading"); + if (result.kind !== "frontmatter") { + expect(result.content).toContain("The thesis."); + // "## Details" is level 2 in the document, but content is de-levelled + // relative to "Overview" (level 1), matching what a content-scope write + // expects back — see the round-trip tests below. + expect(result.content).toContain("# Details"); + expect(result.content).not.toContain("## Details"); + expect(result.content).toContain("Nested body."); + expect(result.content).not.toContain("Elsewhere."); + } + }); + + test("a nested heading is addressed by its path array", () => { + const result = readTarget(DOC, { + targetType: "heading", + target: ["Overview", "Details"], + }); + if (result.kind !== "frontmatter") { + expect(result.content).toContain("Nested body."); + expect(result.content).not.toContain("The thesis."); + } + }); + + test("a block id yields its text", () => { + const result = readTarget(DOC, { targetType: "block", target: "thesis" }); + expect(result.kind).toBe("block"); + if (result.kind !== "frontmatter") { + expect(result.content).toContain("The thesis."); + } + }); + + test("a frontmatter key yields its parsed value", () => { + expect(readTarget(DOC, { targetType: "frontmatter", target: "title" })).toEqual({ + kind: "frontmatter", + value: "Draft", + }); + expect(readTarget(DOC, { targetType: "frontmatter", target: "tags" })).toEqual({ + kind: "frontmatter", + value: ["a", "b"], + }); + }); + + test("a heading's content round-trips through a content-scope write unchanged", () => { + // readTarget's heading content must come back de-leveled the same way a + // content-scope write expects it (relative to the target's own level), or + // reading a section and writing it straight back re-levels every nested + // heading inside it. + const doc = + "# Overview\n\nIntro.\n\n## Details\n\nNested body.\n\n# Other\n\nElsewhere.\n"; + const result = readTarget(doc, { targetType: "heading", target: ["Overview"] }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "heading", + target: ["Overview"], + operation: "replace", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + + test("a nested heading's content round-trips through a content-scope write unchanged", () => { + const doc = + "# Overview\n\n## Details\n\nIntro.\n\n### Sub\n\nDeep body.\n\n# Other\n\nElsewhere.\n"; + const result = readTarget(doc, { + targetType: "heading", + target: ["Overview", "Details"], + }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "heading", + target: ["Overview", "Details"], + operation: "replace", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + + test("a document-root read still round-trips (baseline 0, no releveling needed)", () => { + const doc = "# One\n\nbody\n\n# Two\n\nbody two\n"; + const result = readTarget(doc, { targetType: "heading", target: null }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "heading", + target: null, + operation: "replace", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + + test("within reads one body block of a section, literally", () => { + const doc = "# H\n\nfirst\n\n- one\n- two\n\nlast\n"; + const first = readTarget(doc, { + targetType: "heading", + target: ["H"], + within: 0, + }); + expect(first).toEqual({ kind: "heading", content: "first" }); + const list = readTarget(doc, { + targetType: "heading", + target: ["H"], + within: -2, + }); + expect(list).toEqual({ kind: "heading", content: "- one\n- two" }); + }); + + test("a within read round-trips through a within replace as byte identity", () => { + const doc = "# H\n\nfirst\n\n- one\n- two\n\nlast\n"; + const result = readTarget(doc, { + targetType: "heading", + target: ["H"], + within: 1, + }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "heading", + target: ["H"], + within: 1, + operation: "replace", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + + test("an out-of-range within read throws TargetNotFoundError", () => { + expect(() => + readTarget(DOC, { targetType: "heading", target: ["Other"], within: 5 }) + ).toThrow(TargetNotFoundError); + }); + + describe("scoped reads", () => { + test("marker yields a heading's raw label text", () => { + expect( + readTarget(DOC, { + targetType: "heading", + target: ["Overview", "Details"], + scope: "marker", + }) + ).toEqual({ kind: "heading", content: "Details" }); + }); + + test("marker on the document root throws TargetNotFoundError", () => { + expect(() => + readTarget(DOC, { targetType: "heading", target: null, scope: "marker" }) + ).toThrow(TargetNotFoundError); + }); + + test("markerAndContent yields the subtree at the parent's baseline", () => { + const result = readTarget(DOC, { + targetType: "heading", + target: ["Overview", "Details"], + scope: "markerAndContent", + }); + // Details is h2 in the document; at its parent's (h1) baseline it reads + // as a top-level "# Details" — the shape a markerAndContent replace takes. + expect(result).toEqual({ + kind: "heading", + content: "# Details\n\nNested body.\n", + }); + }); + + test("a heading markerAndContent read round-trips through a markerAndContent replace", () => { + const doc = + "# Overview\n\nIntro.\n\n## Details\n\nNested.\n\n### Sub\n\nDeep.\n\n# Other\n\nElsewhere.\n"; + const result = readTarget(doc, { + targetType: "heading", + target: ["Overview", "Details"], + scope: "markerAndContent", + }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "heading", + target: ["Overview", "Details"], + operation: "replace", + scope: "markerAndContent", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + + test("a heading marker read round-trips through a marker replace", () => { + const doc = "# Overview\n\nIntro.\n"; + const result = readTarget(doc, { + targetType: "heading", + target: ["Overview"], + scope: "marker", + }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "heading", + target: ["Overview"], + operation: "replace", + scope: "marker", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + + test("marker yields a block's bare id; markerAndContent its full span", () => { + expect( + readTarget(DOC, { targetType: "block", target: "thesis", scope: "marker" }) + ).toEqual({ kind: "block", content: "thesis" }); + expect( + readTarget(DOC, { + targetType: "block", + target: "thesis", + scope: "markerAndContent", + }) + ).toEqual({ kind: "block", content: "The thesis. ^thesis" }); + }); + + test("a block markerAndContent read round-trips through a markerAndContent replace", () => { + const doc = "start\n\nThe thesis. ^thesis\n\nend\n"; + const result = readTarget(doc, { + targetType: "block", + target: "thesis", + scope: "markerAndContent", + }); + if (result.kind === "frontmatter") throw new Error("unexpected"); + const written = patch(doc, { + targetType: "block", + target: "thesis", + operation: "replace", + scope: "markerAndContent", + content: result.content, + }); + expect(written.document).toBe(doc); + }); + + test("marker yields a frontmatter key; markerAndContent the whole entry", () => { + expect( + readTarget(DOC, { + targetType: "frontmatter", + target: "title", + scope: "marker", + }) + ).toEqual({ kind: "frontmatter", value: "title" }); + expect( + readTarget(DOC, { + targetType: "frontmatter", + target: "tags", + scope: "markerAndContent", + }) + ).toEqual({ kind: "frontmatter", value: { tags: ["a", "b"] } }); + }); + + test("a within read at a non-content scope throws InvalidInstructionError", () => { + expect(() => + readTarget(DOC, { + targetType: "heading", + target: ["Overview"], + within: 0, + scope: "marker", + }) + ).toThrow(InvalidInstructionError); + }); + }); + + test("an unresolvable target throws TargetNotFoundError", () => { + expect(() => + readTarget(DOC, { targetType: "heading", target: ["Nope"] }) + ).toThrow(TargetNotFoundError); + expect(() => + readTarget(DOC, { targetType: "block", target: "missing" }) + ).toThrow(TargetNotFoundError); + expect(() => + readTarget(DOC, { targetType: "frontmatter", target: "absent" }) + ).toThrow(TargetNotFoundError); + }); +}); diff --git a/src/tests/resolve.test.ts b/src/tests/resolve.test.ts new file mode 100644 index 0000000..de194cd --- /dev/null +++ b/src/tests/resolve.test.ts @@ -0,0 +1,243 @@ +import { buildModel } from "../model"; +import { resolveTarget, resolveHeading, resolveBlock, ResolvedTarget } from "../resolve"; +import { headingPath } from "../projection"; +import { InvalidInstructionError, TargetNotFoundError } from "../instructions"; + +const headingLevel = (r: ResolvedTarget | null): number | null => + r && r.kind === "heading" && r.section.heading ? r.section.heading.level : null; + +const bodyOf = (doc: string, r: ResolvedTarget | null): string => { + if (!r || r.kind !== "heading") return ""; + return doc.slice(r.section.body.start, r.section.body.end); +}; + +describe("resolveHeading", () => { + // Two h1 "A"s: the first has an h2 "B", the second an h3 "B" (skipped h2). + const dupDoc = ["# A", "## B", "body b1", "", "# A", "### B", "body b2", ""].join( + "\n" + ); + + test("null and [] resolve to the document root", () => { + const model = buildModel(dupDoc); + expect(resolveHeading(model, null)?.section).toBe(model.root); + expect(resolveHeading(model, [])?.section).toBe(model.root); + }); + + test("containment path matches by nesting, first in document order", () => { + const model = buildModel(dupDoc); + // ["A","B"] names the first "A"'s child "B" (an h2); the second "A"'s "B" + // (an h3) shares the same containment path but comes later. + const r = resolveHeading(model, ["A", "B"]); + expect(headingLevel(r)).toBe(2); + expect(bodyOf(dupDoc, r)).toContain("body b1"); + }); + + test("a bare, unsuffixed address uniquely names the first occurrence", () => { + const model = buildModel(dupDoc); + const r = resolveHeading(model, ["A"]); + expect(headingLevel(r)).toBe(1); + // The second "A" now has its own disambiguated address (see the next + // test), so ["A"] is no longer "first wins among ambiguous matches" — it + // unambiguously names only the first occurrence. + expect(r?.kind).toBe("heading"); + if (r?.kind === "heading") { + expect(r.section).toBe(model.root.children[0]); + } + }); + + test("a repeated heading's later occurrence resolves via its disambiguated address", () => { + const model = buildModel(dupDoc); + const secondA = model.root.children[1]; + const address = headingPath(secondA); + expect(address).toEqual(["A\u{FC750}\u{F6440}"]); + const r = resolveHeading(model, address); + expect(r?.kind).toBe("heading"); + if (r?.kind === "heading") { + expect(r.section).toBe(secondA); + } + }); + + test("containment path spans a skipped level (garden path)", () => { + const doc = ["# Over", "### Quirk", "x", ""].join("\n"); + const model = buildModel(doc); + // The skipped h2 leaves no hole in the address; ["Over","Quirk"] resolves. + expect(headingLevel(resolveHeading(model, ["Over", "Quirk"]))).toBe(3); + }); + + test("empty-text heading is addressable by its empty-string key", () => { + const doc = ["# ", "under empty", "## Real", "deep", ""].join("\n"); + const model = buildModel(doc); + expect(headingLevel(resolveHeading(model, ["", "Real"]))).toBe(2); + }); + + test("returns null when no heading matches", () => { + const model = buildModel(dupDoc); + expect(resolveHeading(model, ["Nope"])).toBeNull(); + expect(resolveHeading(model, ["A", "C"])).toBeNull(); + }); +}); + +describe("resolveTarget dispatch", () => { + const doc = [ + "---", + "status: draft", + "reviewers:", + " - alice", + "---", + "para with ref ^blk1", + "", + "# H", + "para ^blk2", + "", + ].join("\n"); + + test("resolves a block by bare id", () => { + const model = buildModel(doc); + const r = resolveTarget(model, { targetType: "block", target: "blk1" }); + expect(r?.kind).toBe("block"); + if (r?.kind === "block") { + expect(r.block.id).toBe("blk1"); + expect(r.block.kind).toBe("paragraph"); + } + }); + + test("a duplicate block id's bare form resolves only the first occurrence", () => { + const dupDoc = ["first ^dup", "", "second ^dup", ""].join("\n"); + const model = buildModel(dupDoc); + const r = resolveBlock(model, "dup"); + expect(r?.kind).toBe("block"); + if (r?.kind === "block") { + expect(r.block.content.start).toBe(dupDoc.indexOf("first")); + } + }); + + test("a duplicate block id's later occurrence resolves via its disambiguated address", () => { + const dupDoc = ["first ^dup", "", "second ^dup", ""].join("\n"); + const model = buildModel(dupDoc); + const r = resolveBlock(model, "dup\u{FC750}\u{F6440}"); + expect(r?.kind).toBe("block"); + if (r?.kind === "block") { + expect(r.block.content.start).toBe(dupDoc.indexOf("second")); + } + }); + + test("resolves a frontmatter key", () => { + const model = buildModel(doc); + const r = resolveTarget(model, { + targetType: "frontmatter", + target: "status", + }); + expect(r?.kind).toBe("frontmatter"); + if (r?.kind === "frontmatter") { + expect(r.entry.key).toBe("status"); + expect(r.entry.value).toBe("draft"); + } + }); + + test("resolves a heading via the dispatcher", () => { + const model = buildModel(doc); + const r = resolveTarget(model, { targetType: "heading", target: ["H"] }); + expect(headingLevel(r)).toBe(1); + }); + + test("returns null for missing block and frontmatter targets", () => { + const model = buildModel(doc); + expect( + resolveTarget(model, { targetType: "block", target: "missing" }) + ).toBeNull(); + expect( + resolveTarget(model, { targetType: "frontmatter", target: "missing" }) + ).toBeNull(); + }); +}); + +describe("resolveWithin", () => { + const doc = [ + "# H", + "", + "first para", + "", + "- one", + "- two", + "", + "^listref", + "", + "last para", + "", + "## Sub", + "", + "sub para", + "", + ].join("\n"); + + const childText = (r: ResolvedTarget | null): string => { + if (!r || r.kind !== "headingChild") throw new Error("expected headingChild"); + return doc.slice(r.child.range.start, r.child.range.end); + }; + + test("a positive index selects the Nth body block of the section", () => { + const model = buildModel(doc); + const r = resolveTarget(model, { + targetType: "heading", + target: ["H"], + within: 1, + }); + expect(r?.kind).toBe("headingChild"); + expect(childText(r)).toBe("- one\n- two"); + }); + + test("a negative index counts from the end of the direct body", () => { + const model = buildModel(doc); + // -1 is "last para": the subsection's blocks are not part of H's direct + // body, and the isolated ^listref line is not counted. + const r = resolveTarget(model, { + targetType: "heading", + target: ["H"], + within: -1, + }); + expect(childText(r)).toBe("last para"); + expect((r as { index: number }).index).toBe(2); + }); + + test("within over the document root addresses the preamble", () => { + const preambleDoc = "Preamble.\n\n# First\n\nbody\n"; + const model = buildModel(preambleDoc); + const r = resolveTarget(model, { + targetType: "heading", + target: null, + within: 0, + }); + expect(r?.kind).toBe("headingChild"); + if (r?.kind === "headingChild") { + expect(preambleDoc.slice(r.child.range.start, r.child.range.end)).toBe( + "Preamble." + ); + } + }); + + test("an out-of-range index throws TargetNotFoundError naming the count", () => { + const model = buildModel(doc); + for (const within of [3, -4]) { + expect(() => + resolveTarget(model, { targetType: "heading", target: ["H"], within }) + ).toThrow(TargetNotFoundError); + expect(() => + resolveTarget(model, { targetType: "heading", target: ["H"], within }) + ).toThrow(/has 3 top-level blocks/); + } + }); + + test("a non-integer index throws InvalidInstructionError", () => { + const model = buildModel(doc); + expect(() => + resolveTarget(model, { targetType: "heading", target: ["H"], within: 0.5 }) + ).toThrow(InvalidInstructionError); + }); + + test("a missing heading still returns null, within or not", () => { + const model = buildModel(doc); + expect( + resolveTarget(model, { targetType: "heading", target: ["Nope"], within: 0 }) + ).toBeNull(); + }); +}); diff --git a/src/tests/safety.test.ts b/src/tests/safety.test.ts new file mode 100644 index 0000000..5418a15 --- /dev/null +++ b/src/tests/safety.test.ts @@ -0,0 +1,68 @@ +import { patch, ContentPreexistsError, Instruction } from "../index"; + +describe("rejectIfContentPreexists", () => { + const DOC = "# A\nalready here\n"; + + test("append is refused when the content already appears in the target span", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "already here", + rejectIfContentPreexists: true, + }) + ).toThrow(ContentPreexistsError); + }); + + test("append proceeds when the content is not already present", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "new line", + rejectIfContentPreexists: true, + }); + expect(result.document).toBe("# A\nalready here\n\nnew line\n"); + }); + + test("replace is never blocked by the guard (it overwrites)", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "content", + content: "already here", + rejectIfContentPreexists: true, + }); + expect(result.document).toBe("# A\nalready here\n"); + }); + + test("the guard does not fire without the flag", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "append", + scope: "content", + content: "already here", + }); + expect(result.document).toBe("# A\nalready here\n\nalready here\n"); + }); +}); + +describe("public 2.0 exports", () => { + test("patch and the Instruction type are re-exported from the package root", () => { + const instruction: Instruction = { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "content", + content: "x", + }; + const result = patch("# A\nold\n", instruction); + expect(result.document).toBe("# A\nx\n"); + expect(result.warnings).toEqual([]); + }); +}); diff --git a/src/tests/schema.test.ts b/src/tests/schema.test.ts new file mode 100644 index 0000000..222cd41 --- /dev/null +++ b/src/tests/schema.test.ts @@ -0,0 +1,312 @@ +import { z } from "zod"; + +import { + InstructionInputSchema, + InstructionInputObjectSchema, +} from "../schema"; +import { patch } from "../engine"; +import { + InstructionInput, + InvalidInstructionError, +} from "../instructions"; + +// --- Type-level drift guard ---------------------------------------------- +// +// Every member of the hand-written InstructionInput union must be accepted by +// the flat schema's input type. If the union grows a field or tightens a type +// the schema does not mirror, this assignment stops compiling — which, under +// ts-jest, fails the suite. (The reverse does not hold and is not expected: the +// flat schema is looser at compile time and tightened at runtime by the +// superRefine, so a bare object without its carrier is a valid *type* but a +// runtime error.) +const _unionSatisfiesSchema: ( + i: InstructionInput +) => z.input = (i) => i; +void _unionSatisfiesSchema; + +// A representative instance of each union member, to exercise the schema at +// runtime the way callers actually use it. +const valid: { name: string; instruction: InstructionInput }[] = [ + { + name: "heading write @ content", + instruction: { targetType: "heading", target: ["A"], operation: "append", content: "x" }, + }, + { + name: "heading write @ marker", + instruction: { targetType: "heading", target: ["A"], operation: "replace", scope: "marker", content: "New" }, + }, + { + name: "heading move @ parent", + instruction: { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "parent", + destination: { parent: null, place: "last" }, + }, + }, + { + name: "heading delete", + instruction: { targetType: "heading", target: ["A"], operation: "delete", scope: "markerAndContent" }, + }, + { + name: "block write @ content", + instruction: { targetType: "block", target: "abc", operation: "append", content: "row" }, + }, + { + name: "block marker replace", + instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", content: "def" }, + }, + { + name: "block target/marker with hyphens and underscores", + instruction: { targetType: "block", target: "a-b_c1", operation: "replace", scope: "marker", content: "x-y_2" }, + }, + { + name: "block table-row write", + instruction: { targetType: "block", target: "abc", operation: "append", value: [["a", "b"]] }, + }, + { + name: "block target disambiguated by the duplicate-marker suffix", + instruction: { + targetType: "block", + target: "abc\u{FC750}\u{F6440}", + operation: "replace", + content: "x", + }, + }, + { + name: "frontmatter value write", + instruction: { targetType: "frontmatter", target: "title", operation: "replace", value: "T" }, + }, + { + name: "frontmatter value merge", + instruction: { targetType: "frontmatter", target: "tags", operation: "append", value: ["x"] }, + }, + { + name: "frontmatter rename", + instruction: { targetType: "frontmatter", target: "a", operation: "replace", scope: "marker", content: "b" }, + }, + { + name: "frontmatter delete", + instruction: { targetType: "frontmatter", target: "a", operation: "delete" }, + }, + { + name: "heading within write (literal splice into a body block)", + instruction: { targetType: "heading", target: ["A"], within: -1, operation: "append", content: "\n- x" }, + }, + { + name: "heading within delete", + instruction: { targetType: "heading", target: ["A"], within: 0, operation: "delete" }, + }, + { + name: "heading within sibling insert", + instruction: { + targetType: "heading", + target: ["A"], + within: 1, + operation: "prepend", + scope: "markerAndContent", + content: "new block", + }, + }, +]; + +describe("InstructionInputSchema", () => { + test.each(valid)("accepts a $name", ({ instruction }) => { + expect(InstructionInputSchema.safeParse(instruction).success).toBe(true); + }); + + test("defaults an omitted scope to content and the flags to false", () => { + const parsed = InstructionInputSchema.parse({ + targetType: "heading", + target: ["A"], + operation: "append", + content: "x", + }); + expect(parsed.scope).toBe("content"); + expect(parsed.createTargetIfMissing).toBe(false); + expect(parsed.rejectIfContentPreexists).toBe(false); + }); + + describe("rejects malformed instructions", () => { + const invalid: { name: string; instruction: unknown }[] = [ + { + name: "a heading target given as a string", + instruction: { targetType: "heading", target: "A", operation: "append", content: "x" }, + }, + { + name: "a block target given as an array", + instruction: { targetType: "block", target: ["a"], operation: "append", content: "x" }, + }, + { + name: "an invalid cell (parent on a block)", + instruction: { + targetType: "block", + target: "a", + operation: "replace", + scope: "parent", + destination: { parent: null, place: "last" }, + }, + }, + { + name: "an invalid cell (prepend on a frontmatter marker)", + instruction: { targetType: "frontmatter", target: "a", operation: "prepend", scope: "marker", content: "b" }, + }, + { + name: "a heading write carrying value instead of content", + instruction: { targetType: "heading", target: ["A"], operation: "replace", value: 1 }, + }, + { + name: "a frontmatter value write carrying content instead of value", + instruction: { targetType: "frontmatter", target: "a", operation: "replace", content: "x" }, + }, + { + name: "a heading write missing its content carrier", + instruction: { targetType: "heading", target: ["A"], operation: "replace" }, + }, + { + name: "a move missing its destination carrier", + instruction: { targetType: "heading", target: ["A"], operation: "replace", scope: "parent" }, + }, + { + name: "a delete carrying content", + instruction: { targetType: "heading", target: ["A"], operation: "delete", content: "x" }, + }, + { + name: "a block content write carrying both content and value", + instruction: { + targetType: "block", + target: "abc", + operation: "append", + content: "x", + value: [["a", "b"]], + }, + }, + { + name: "a block content write with a value that isn't a 2-D array of strings", + instruction: { targetType: "block", target: "abc", operation: "append", value: ["a", "b"] }, + }, + { + name: "a block content write with a value row containing a non-string cell", + instruction: { targetType: "block", target: "abc", operation: "append", value: [["a", 1]] }, + }, + { + name: "a value on a block marker cell (only content/value on `content` scope)", + instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", value: [["a"]] }, + }, + { + name: "a block target containing a space", + instruction: { targetType: "block", target: "has space", operation: "append", content: "x" }, + }, + { + name: "a block marker rename to an id containing a space", + instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", content: "new id" }, + }, + { + name: "a block marker rename to an id carrying the duplicate-marker suffix", + instruction: { + targetType: "block", + target: "abc", + operation: "replace", + scope: "marker", + content: "def\u{FC750}\u{F6440}", + }, + }, + { + name: "a block marker rename to an id containing a caret", + instruction: { targetType: "block", target: "abc", operation: "replace", scope: "marker", content: "^def" }, + }, + { + name: "a heading marker rename containing an embedded newline", + instruction: { targetType: "heading", target: ["A"], operation: "replace", scope: "marker", content: "New\nline" }, + }, + { + name: "within on a block target", + instruction: { targetType: "block", target: "abc", within: 0, operation: "append", content: "x" }, + }, + { + name: "within on a frontmatter target", + instruction: { targetType: "frontmatter", target: "a", within: 0, operation: "replace", value: "v" }, + }, + { + name: "a non-integer within", + instruction: { targetType: "heading", target: ["A"], within: 0.5, operation: "append", content: "x" }, + }, + { + name: "within with marker scope", + instruction: { targetType: "heading", target: ["A"], within: 0, operation: "replace", scope: "marker", content: "x" }, + }, + { + name: "within with parent scope", + instruction: { + targetType: "heading", + target: ["A"], + within: 0, + operation: "replace", + scope: "parent", + destination: { parent: null, place: "last" }, + }, + }, + { + name: "a within replace @ markerAndContent (content covers it)", + instruction: { + targetType: "heading", + target: ["A"], + within: 0, + operation: "replace", + scope: "markerAndContent", + content: "x", + }, + }, + { + name: "a within delete @ markerAndContent (content covers it)", + instruction: { + targetType: "heading", + target: ["A"], + within: 0, + operation: "delete", + scope: "markerAndContent", + }, + }, + { + name: "within combined with createTargetIfMissing", + instruction: { + targetType: "heading", + target: ["A"], + within: 0, + operation: "append", + content: "x", + createTargetIfMissing: true, + }, + }, + ]; + + test.each(invalid)("rejects $name", ({ instruction }) => { + expect(InstructionInputSchema.safeParse(instruction).success).toBe(false); + }); + }); +}); + +describe("patch() boundary validation", () => { + test("throws InvalidInstructionError for a malformed instruction", () => { + expect(() => + // A heading write with no content carrier: valid cell, wrong shape. + patch("# A\n\nbody\n", { + targetType: "heading", + target: ["A"], + operation: "replace", + } as InstructionInput) + ).toThrow(InvalidInstructionError); + }); + + test("applies a well-formed instruction", () => { + const { document } = patch("# A\n\nbody\n", { + targetType: "frontmatter", + target: "title", + operation: "replace", + value: "Set", + createTargetIfMissing: true, + }); + expect(document).toContain("title: Set"); + }); +}); diff --git a/src/tests/splice.test.ts b/src/tests/splice.test.ts new file mode 100644 index 0000000..62a2c70 --- /dev/null +++ b/src/tests/splice.test.ts @@ -0,0 +1,159 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { applyEdits, spliceRange, OverlappingEditsError } from "../splice"; +import { + buildModel, + eachSection, + SectionNode, +} from "../model"; +import { + lastDescendant, + subtreeContentRange, + subtreeEnd, + headingMarkerRange, + blockFullRange, + RootHasNoMarkerError, +} from "../ranges"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const CONFORMANCE_DIR = path.join(__dirname, "conformance"); + +describe("applyEdits", () => { + const doc = "0123456789"; + + test("replaces a single range", () => { + expect(spliceRange(doc, { start: 2, end: 5 }, "XYZ")).toBe("01XYZ56789"); + }); + + test("inserts at a point (empty range)", () => { + expect(spliceRange(doc, { start: 3, end: 3 }, "--")).toBe("012--3456789"); + }); + + test("stitches multiple non-overlapping edits regardless of order", () => { + const result = applyEdits(doc, [ + { range: { start: 7, end: 8 }, text: "B" }, + { range: { start: 1, end: 2 }, text: "A" }, + ]); + expect(result).toBe("0A23456B89"); + }); + + test("throws on overlapping edits", () => { + expect(() => + applyEdits(doc, [ + { range: { start: 2, end: 5 }, text: "x" }, + { range: { start: 4, end: 6 }, text: "y" }, + ]) + ).toThrow(OverlappingEditsError); + }); + + test("throws on an inverted range", () => { + expect(() => spliceRange(doc, { start: 5, end: 2 }, "x")).toThrow( + OverlappingEditsError + ); + }); +}); + +describe("splice no-op identity (property over fixtures)", () => { + const fixtures = fs + .readdirSync(CONFORMANCE_DIR) + .filter((f) => f.endsWith(".md")) + .map((f) => fs.readFileSync(path.join(CONFORMANCE_DIR, f), "utf-8")); + + test("splicing every node's own text back is identity", () => { + for (const doc of fixtures) { + const model = buildModel(doc); + const edits: Array<{ range: { start: number; end: number }; text: string }> = + []; + eachSection(model.root, (node) => { + if (node.marker) { + edits.push({ + range: node.marker, + text: doc.slice(node.marker.start, node.marker.end), + }); + } + edits.push({ + range: node.body, + text: doc.slice(node.body.start, node.body.end), + }); + }); + // Edits are node markers/contents, which are disjoint and in order. + expect(applyEdits(doc, edits)).toBe(doc); + } + }); +}); + +describe("subtree ranges", () => { + const doc = [ + "# A", // 0 + "a-body", // + "", // + "## B", // + "b-body", // + "", // + "# C", // + "c-body", // + "", // + ].join("\n"); + + const sectionByText = (text: string): SectionNode => { + let found: SectionNode | undefined; + eachSection(buildModel(doc).root, (n) => { + if (n.heading?.text === text) found = n; + }); + if (!found) throw new Error(`no section ${text}`); + return found; + }; + + test("lastDescendant walks to the deepest last child", () => { + const a = sectionByText("A"); + expect(lastDescendant(a).heading?.text).toBe("B"); + }); + + test("subtreeContentRange covers the section and its descendants but not the next sibling", () => { + const a = sectionByText("A"); + const span = doc.slice( + subtreeContentRange(a).start, + subtreeContentRange(a).end + ); + expect(span).toContain("# A"); + expect(span).toContain("a-body"); + expect(span).toContain("## B"); + expect(span).toContain("b-body"); + expect(span).not.toContain("# C"); + }); + + test("subtreeEnd includes the trailing separator gap", () => { + const a = sectionByText("A"); + expect(subtreeEnd(a)).toBeGreaterThan(subtreeContentRange(a).end); + // The gap between B's body and C is a single blank line. + expect(doc.slice(subtreeContentRange(a).end, subtreeEnd(a))).toBe("\n"); + }); + + test("headingMarkerRange returns the heading line and rejects the root", () => { + const a = sectionByText("A"); + expect(doc.slice(headingMarkerRange(a).start, headingMarkerRange(a).end)).toBe( + "# A\n" + ); + expect(() => headingMarkerRange(buildModel(doc).root)).toThrow( + RootHasNoMarkerError + ); + }); +}); + +describe("block ranges", () => { + test("blockFullRange spans an inline id's content through its marker", () => { + const doc = "a paragraph ^ref\n"; + const model = buildModel(doc); + let range: { start: number; end: number } | undefined; + eachSection(model.root, (n) => + n.blocks.forEach((b) => { + if (b.id === "ref") range = blockFullRange(b); + }) + ); + expect(range).toBeDefined(); + expect(doc.slice(range!.start, range!.end)).toBe("a paragraph ^ref"); + }); +}); diff --git a/src/tests/structural.test.ts b/src/tests/structural.test.ts new file mode 100644 index 0000000..dd6f1de --- /dev/null +++ b/src/tests/structural.test.ts @@ -0,0 +1,259 @@ +import { patch } from "../engine"; +import { EngineError, TargetNotFoundError } from "../instructions"; + +const DOC = "# A\na-body\n\n## B\nb-body\n\n# C\nc-body\n"; + +describe("patch — heading delete cells", () => { + test("delete @ content empties the subtree body, keeping the heading and gap", () => { + // A's content spans through its ## B subsection, so emptying it removes the + // subsection too, leaving only the bare heading and the following gap. + const result = patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "delete", + scope: "content", + }); + expect(result.document).toBe("# A\n\n# C\nc-body\n"); + }); + + test("delete @ content on a leaf empties just that section's body", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "delete", + scope: "content", + }); + expect(result.document).toBe("# A\na-body\n\n## B\n\n# C\nc-body\n"); + }); + + test("delete @ markerAndContent removes the subtree and its trailing gap", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "delete", + scope: "markerAndContent", + }); + expect(result.document).toBe("# A\na-body\n\n# C\nc-body\n"); + }); +}); + +describe("patch — dissolve (delete @ marker)", () => { + test("with no preceding same-level sibling, children are promoted and re-levelled up one", () => { + const doc = "# A\n## S\n### child\nx\n"; + const result = patch(doc, { + targetType: "heading", + target: ["A", "S"], + operation: "delete", + scope: "marker", + }); + expect(result.document).toBe("# A\n## child\nx\n"); + }); + + test("with a preceding same-level sibling, the heading is simply removed and children absorbed", () => { + const doc = "# A\n## P\n## S\n### child\nx\n"; + const result = patch(doc, { + targetType: "heading", + target: ["A", "S"], + operation: "delete", + scope: "marker", + }); + expect(result.document).toBe("# A\n## P\n### child\nx\n"); + }); + + test("dissolving a childless heading is a pure one-line deletion", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "delete", + scope: "marker", + }); + expect(result.document).toBe("# A\na-body\n\nb-body\n\n# C\nc-body\n"); + }); + + test("dissolving the document root is rejected", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: null, + operation: "delete", + scope: "marker", + }) + ).toThrow(); + }); +}); + +describe("patch — move (replace @ parent)", () => { + test("moves a subsection to the document root, re-levelling it down to h1", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "parent", + destination: { parent: null, place: "last" }, + }); + expect(result.document).toBe( + "# A\na-body\n\n# C\nc-body\n# B\nb-body\n" + ); + }); + + test("moves a top-level section beneath a sibling, re-levelling it up", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["C"], + operation: "replace", + scope: "parent", + destination: { parent: ["A"], place: "last" }, + }); + // C (h1) becomes a child of A (h1) -> h2, appended after B. + expect(result.document).toBe( + "# A\na-body\n\n## B\nb-body\n\n## C\nc-body\n" + ); + }); + + test("place `before` a named sibling inserts the moved subtree ahead of it", () => { + const result = patch(DOC, { + targetType: "heading", + target: ["C"], + operation: "replace", + scope: "parent", + destination: { parent: ["A"], place: { before: ["A", "B"] } }, + }); + expect(result.document).toBe( + "# A\na-body\n\n## C\nc-body\n## B\nb-body\n\n" + ); + }); + + test("re-levelling preserves the internal nesting of the moved subtree", () => { + const doc = "# A\n## B\n### deep\nd\n\n# C\nc\n"; + const result = patch(doc, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "parent", + destination: { parent: ["C"], place: "last" }, + }); + // B (h2) -> child of C (h1) -> h2, deep (h3) shifts with it -> h3. + expect(result.document).toContain("# C\nc\n## B\n### deep\nd\n"); + }); + + test("moving a section beneath itself is rejected", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: ["A"], + operation: "replace", + scope: "parent", + destination: { parent: ["A", "B"], place: "last" }, + }) + ).toThrow(EngineError); + }); + + test("a level-neutral move to where the section already sits is a byte-identical no-op", () => { + const doc = "# P\n\n## S1\na\n\n## S2\nb\n"; + const afterSelf = patch(doc, { + targetType: "heading", + target: ["P", "S1"], + operation: "replace", + scope: "parent", + destination: { parent: ["P"], place: { after: ["P", "S1"] } }, + }); + expect(afterSelf.document).toBe(doc); + + const beforeFollower = patch(doc, { + targetType: "heading", + target: ["P", "S1"], + operation: "replace", + scope: "parent", + destination: { parent: ["P"], place: { before: ["P", "S2"] } }, + }); + expect(beforeFollower.document).toBe(doc); + }); + + test("a same-position move across parents re-levels in place, keeping separators", () => { + // B is A's last child and the destination (root, before C) is the same + // textual spot: only the levels change, and B's blank-line separator + // before C survives. + const result = patch("# A\na\n\n## B\nb\n\n# C\nc\n", { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "parent", + destination: { parent: null, place: { before: ["C"] } }, + }); + expect(result.document).toBe("# A\na\n\n# B\nb\n\n# C\nc\n"); + }); + + test("moving beneath a parent whose last line has no terminator starts a fresh line", () => { + // The destination offset is the end of a document with no trailing + // newline; without the owed line start the moved marker would glue onto + // the parent's last body line ("…no newline## Move me"). + const doc = "# A\n\n## Move me\n\nbody\n\n# B\nlast line no newline"; + const result = patch(doc, { + targetType: "heading", + target: ["A", "Move me"], + operation: "replace", + scope: "parent", + destination: { parent: ["B"], place: "last" }, + }); + expect(result.document).toBe( + "# A\n\n# B\nlast line no newline\n## Move me\n\nbody\n" + ); + }); + + test("an unresolvable new parent raises TargetNotFoundError", () => { + expect(() => + patch(DOC, { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "parent", + destination: { parent: ["Nope"], place: "last" }, + }) + ).toThrow(TargetNotFoundError); + }); +}); + +describe("patch — block delete cells", () => { + const BLOCK_DOC = "a paragraph ^ref\n"; + + test("delete @ content empties the block text, keeping the id", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "delete", + scope: "content", + }); + expect(result.document).toBe(" ^ref\n"); + }); + + test("delete @ marker detaches the id, keeping the content", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "delete", + scope: "marker", + }); + expect(result.document).toBe("a paragraph\n"); + }); + + test("delete @ markerAndContent removes the block and its trailing gap", () => { + const result = patch(BLOCK_DOC, { + targetType: "block", + target: "ref", + operation: "delete", + scope: "markerAndContent", + }); + expect(result.document).toBe(""); + }); + + test("delete @ markerAndContent consumes the blank line separating a following block", () => { + const doc = "a ^x\n\nb ^y\n"; + const result = patch(doc, { + targetType: "block", + target: "x", + operation: "delete", + scope: "markerAndContent", + }); + expect(result.document).toBe("b ^y\n"); + }); +}); diff --git a/src/tests/symmetry.test.ts b/src/tests/symmetry.test.ts new file mode 100644 index 0000000..11500ff --- /dev/null +++ b/src/tests/symmetry.test.ts @@ -0,0 +1,183 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { patch } from "../engine"; +import { Instruction } from "../instructions"; +import { buildModel, eachSection, serializeModel } from "../model"; +import { projectMap, headingPath, headingTreePaths } from "../projection"; +import { headingContentRange } from "../ranges"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const CONFORMANCE_DIR = path.join(__dirname, "conformance"); + +const arrayEquals = (a: unknown[], b: unknown[]): boolean => + a.length === b.length && a.every((value, index) => value === b[index]); + +const hasHeading = (document: string, wanted: string[]): boolean => + headingTreePaths(projectMap(buildModel(document)).headings).some((heading) => + arrayEquals(heading, wanted) + ); + +// The top design constraint: a successful, non-overflow write leaves its target +// addressable in the map derived from the result. +describe("map/patch symmetry — written targets are addressable in the next map", () => { + const DOC = "# A\na-body\n\n## B\nb-body\n\n# C\nc-body\n"; + const FM = "---\ntitle: Hello\n---\nbody\n"; + + const cases: Array<{ + name: string; + document: string; + instruction: Instruction; + check: (result: string) => boolean; + }> = [ + { + name: "content replace keeps the heading addressable", + document: DOC, + instruction: { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "content", + content: "z", + }, + check: (r) => hasHeading(r, ["A", "B"]), + }, + { + name: "markerAndContent replace makes the renamed heading addressable", + document: DOC, + instruction: { + targetType: "heading", + target: ["A", "B"], + operation: "replace", + scope: "markerAndContent", + content: "# Renamed\nx", + }, + check: (r) => hasHeading(r, ["A", "Renamed"]), + }, + { + name: "created heading is addressable", + document: DOC, + instruction: { + targetType: "heading", + target: ["A", "New"], + operation: "replace", + scope: "content", + content: "body", + createTargetIfMissing: true, + }, + check: (r) => hasHeading(r, ["A", "New"]), + }, + { + name: "moved heading is addressable under its new parent", + document: DOC, + instruction: { + targetType: "heading", + target: ["C"], + operation: "replace", + scope: "parent", + destination: { parent: ["A"], place: "last" }, + }, + check: (r) => hasHeading(r, ["A", "C"]), + }, + { + name: "sibling insert makes the new section addressable", + document: DOC, + instruction: { + targetType: "heading", + target: ["A", "B"], + operation: "prepend", + scope: "markerAndContent", + content: "# Sib\ns", + }, + check: (r) => hasHeading(r, ["A", "Sib"]), + }, + { + name: "created block is addressable", + document: DOC, + instruction: { + targetType: "block", + target: "blk", + operation: "replace", + scope: "content", + content: "a block", + createTargetIfMissing: true, + }, + check: (r) => projectMap(buildModel(r)).blocks.includes("blk"), + }, + { + name: "created frontmatter key is addressable", + document: FM, + instruction: { + targetType: "frontmatter", + target: "author", + operation: "replace", + scope: "content", + value: "me", + createTargetIfMissing: true, + }, + check: (r) => + projectMap(buildModel(r)).frontmatterFields.includes("author"), + }, + ]; + + for (const testCase of cases) { + test(testCase.name, () => { + const result = patch(testCase.document, testCase.instruction); + expect(result.warnings).toEqual([]); + expect(testCase.check(result.document)).toBe(true); + // The result must itself be a well-formed model (lossless partition). + expect(serializeModel(result.document, buildModel(result.document))).toBe( + result.document + ); + }); + } +}); + +// A content-scope replace must not disturb any byte outside the edited body. +describe("splice locality — content edits are confined to the target body", () => { + const fixtures = fs + .readdirSync(CONFORMANCE_DIR) + .filter((file) => file.endsWith(".md")) + .map((file) => fs.readFileSync(path.join(CONFORMANCE_DIR, file), "utf-8")); + + test("prefix and suffix around a section's body are byte-preserved", () => { + for (const document of fixtures) { + const model = buildModel(document); + + // Only target headings whose padded address is unique, so the resolver + // cannot pick a different occurrence than the one we measured. + const paths = new Map(); + eachSection(model.root, (section) => { + if (section.heading) { + const key = JSON.stringify(headingPath(section)); + paths.set(key, (paths.get(key) ?? 0) + 1); + } + }); + + eachSection(model.root, (section) => { + if (!section.heading) { + return; + } + if (paths.get(JSON.stringify(headingPath(section))) !== 1) { + return; + } + // `content` scope spans the subtree below the heading (span B), so the + // preserved suffix begins where that span ends, not at the direct body. + const body = headingContentRange(section); + const before = document.slice(0, body.start); + const after = document.slice(body.end); + const result = patch(document, { + targetType: "heading", + target: headingPath(section), + operation: "replace", + scope: "content", + content: "LOCALMARK", + }).document; + expect(result.startsWith(before)).toBe(true); + expect(result.endsWith(after)).toBe(true); + }); + } + }); +}); diff --git a/src/text.ts b/src/text.ts new file mode 100644 index 0000000..2d95df8 --- /dev/null +++ b/src/text.ts @@ -0,0 +1,145 @@ +/** + * Text helpers shared by the engine's write and structural handlers: line-ending + * normalization and relative→absolute heading fragment preparation. Kept in + * their own module so `engine.ts` and `engine/structural.ts` can share them + * without a circular import. + */ + +import { rebaseHeadings } from "./levels.js"; +import { applyEdits, Edit } from "./splice.js"; +import { PatchResult, Warning } from "./instructions.js"; + +export type LineEnding = "\n" | "\r\n"; + +/** Apply `edits` to `document` and package the result with its `warnings`. */ +export const splice = ( + document: string, + edits: Edit[], + warnings: Warning[] +): PatchResult => ({ document: applyEdits(document, edits), warnings }); + +const normalizeToLf = (text: string): string => + text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + +/** Re-express `text` using the document's line ending. */ +export const toLineEnding = (text: string, ending: LineEnding): string => + ending === "\n" + ? normalizeToLf(text) + : normalizeToLf(text).replace(/\n/g, "\r\n"); + +/** + * Normalize an inserted body/section fragment to end with exactly one line + * ending (or empty for an empty value), matching the model's invariant that a + * non-empty content span ends with a single terminator and the library owns the + * blank-line gap that follows. + */ +export const endWithSingleEol = (text: string, ending: LineEnding): string => { + const stripped = text.replace(/(?:\r\n|\r|\n)+$/, ""); + return stripped.length === 0 ? "" : stripped + ending; +}; + +/** + * Reduce a caller-supplied fragment to trimmed, canonical form: leading blank + * lines and all trailing whitespace are dropped, and a non-empty fragment ends + * with exactly one line ending. Interior lines — including first-line + * indentation — are untouched. This is what makes leading/trailing newlines in + * caller content meaningless: separators are the library's job, not a channel + * for the caller to steer. + */ +export const canonicalFragment = (text: string, ending: LineEnding): string => { + const stripped = text + .replace(/^(?:[^\S\r\n]*(?:\r\n|\r|\n))+/, "") + .replace(/\s+$/, ""); + return stripped.length === 0 ? "" : stripped + ending; +}; + +/** Line endings (a `\r\n` pair counts as one) immediately before `at`, up to 2. */ +const eolsBefore = (document: string, at: number): number => { + let count = 0; + let i = at; + while (count < 2 && i > 0) { + if (document[i - 1] === "\n") { + i -= document[i - 2] === "\r" ? 2 : 1; + } else if (document[i - 1] === "\r") { + i -= 1; + } else { + break; + } + count += 1; + } + return count; +}; + +/** + * The line ending owed so an insertion at `at` begins at a line start: nothing + * at the document start or after an existing line ending, one ending when the + * preceding character is flush text (a document whose last line has no + * terminator). This is the floor below {@link ownedGaps}' blank-line + * separator: a joint that owes no *blank line* — a heading-led fragment, a + * body write directly under its marker — still must not begin mid-line. + */ +export const lineStartGap = ( + document: string, + at: number, + ending: LineEnding +): string => + at === 0 || document[at - 1] === "\n" || document[at - 1] === "\r" + ? "" + : ending; + +/** + * The blank-line separators the library owes around a block-level fragment + * spliced over `range` (a collapsed range for a pure insertion). A non-empty + * fragment always ends in a single line ending, so on each side the fragment + * must be separated from whatever survives there by a blank line: a document + * edge needs nothing, an existing blank line suffices, and flush text gets + * exactly the endings required. Separators are only ever added — existing + * gap bytes are never rewritten. + */ +export const ownedGaps = ( + document: string, + range: { start: number; end: number }, + ending: LineEnding +): { before: string; after: string } => { + const before = + range.start === 0 ? "" : ending.repeat(Math.max(0, 2 - eolsBefore(document, range.start))); + const after = + range.end === document.length || + document[range.end] === "\n" || + document[range.end] === "\r" + ? "" + : ending; + return { before, after }; +}; + +/** + * Turn a relative heading-bearing fragment into the exact bytes to splice in: + * rebase its `#`-levels by `baseline`, re-apply the document's line ending, and + * reduce it to canonical form (blank edges trimmed, single terminator). + */ +export const sectionFragment = ( + value: string, + baseline: number, + ending: LineEnding +): { text: string; warnings: Warning[] } => { + const rebased = rebaseHeadings(value, baseline); + return { + text: canonicalFragment(toLineEnding(rebased.text, ending), ending), + warnings: rebased.warnings, + }; +}; + +/** + * Re-level an already-placed heading fragment by `delta`, preserving its own + * trailing structure (no single-eol normalization). Used by move and dissolve + * to shift a contiguous subtree's `#`-levels in place. Returns `null` edits + * when `delta` is 0. + */ +export const relevelText = ( + slice: string, + delta: number, + ending: LineEnding +): { text: string; warnings: Warning[] } => { + const rebased = rebaseHeadings(slice, delta); + return { text: toLineEnding(rebased.text, ending), warnings: rebased.warnings }; +}; diff --git a/src/typeGuards.ts b/src/typeGuards.ts index b135037..92863be 100644 --- a/src/typeGuards.ts +++ b/src/typeGuards.ts @@ -1,29 +1,8 @@ -import { AppendableFrontmatterType } from "./types"; - -export function isStringArray(obj: unknown): obj is string[] { - // Check if the object is an array - if (!Array.isArray(obj)) return false; - - // Check if every element is a string - return obj.every((item) => typeof item === "string"); -} - -export function isStringArrayArray(obj: unknown): obj is string[][] { - // Check if the object is an array - if (!Array.isArray(obj)) return false; - - // Check if every element is an array of strings - return obj.every( - (item) => - Array.isArray(item) && - item.every((subItem) => typeof subItem === "string") - ); -} -export function isAppendableFrontmatterType( - obj: unknown -): obj is AppendableFrontmatterType { - return isString(obj) || isDictionary(obj) || isList(obj); -} +/** The value shapes a frontmatter `prepend`/`append` can merge into. */ +export type AppendableFrontmatterType = + | string + | Array + | Record; export function isString(obj: unknown): obj is string { return typeof obj === "string"; @@ -36,3 +15,9 @@ export function isDictionary(obj: unknown): obj is Record { export function isList(obj: unknown): obj is Array { return Array.isArray(obj); } + +export function isAppendableFrontmatterType( + obj: unknown +): obj is AppendableFrontmatterType { + return isString(obj) || isDictionary(obj) || isList(obj); +} diff --git a/src/types.ts b/src/types.ts index 829a36e..cf0fa74 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,303 +1,5 @@ +/** A half-open byte range `[start, end)` into a document string. */ export interface DocumentRange { start: number; end: number; } - -export interface DocumentMapMarkerContentPair { - marker: DocumentRange; - content: DocumentRange; -} - -export interface HeadingMarkerContentPair extends DocumentMapMarkerContentPair { - level: number; -} - -export interface DocumentMap { - heading: Record; - block: Record; - frontmatter: Record; - contentOffset: number; - lineEnding: string; -} - -export type PatchTargetType = "heading" | "block" | "frontmatter"; - -export type PatchOperation = "replace" | "prepend" | "append"; - -export interface BasePatchInstructionTarget { - targetType: PatchTargetType; - target: any; - createTargetIfMissing?: boolean; -} - -export interface BasePatchInstructionOperation { - operation: string; -} - -export type PatchTargetScope = "content" | "marker" | "markerAndContent"; - -export interface BaseMarkerContentPatchInstruction - extends BasePatchInstructionTarget { - /** Controls the range of the document the patch operates on. - * - * - `"content"` (default): patch applies only to the content region, leaving the marker unchanged. - * - `"marker"`: patch applies only to the marker (heading line or block ID), leaving the content unchanged. - * - `"markerAndContent"`: patch applies to the full range covering both the marker and content, - * allowing the heading line or block ID to be replaced or repositioned alongside the content. - */ - targetScope?: PatchTargetScope; -} - -export interface BaseHeadingPatchInstruction - extends BaseMarkerContentPatchInstruction { - targetType: "heading"; - target: string[] | null; -} - -export interface BaseFrontmatterPatchInstruction - extends BasePatchInstructionTarget { - targetType: "frontmatter"; - target: string; -} - -export interface BaseBlockPatchInstruction - extends BaseMarkerContentPatchInstruction { - targetType: "block"; - target: string; -} - -export interface NonExtendingPatchInstruction - extends BasePatchInstructionOperation {} - -export interface TextExtendingPatchInstruction - extends BasePatchInstructionOperation { - /** Trim whitepsace from target before joining with content - * - * - For `prepend`: Trims content from the beginning of - * the target content. - * - For `append`: Trims content from the end of the target - * content. Your content should probably end in a newline - * in this case, or the trailing heading will no longer - * be the start of its own line - */ - trimTargetWhitespace?: boolean; - /** Reject patch if content already exists at target - * - * By default, a patch is always applied regardless of whether the - * supplied content already appears in the target. Set - * `rejectIfContentPreexists` to `true` to instead fail with - * `ContentAlreadyPreexistsInTarget` when the content is found — - * useful as an idempotency guard so a retry does not duplicate content. - */ - rejectIfContentPreexists?: boolean; -} - -export interface StringContent { - contentType?: ContentType.text; - content: string; -} - -export interface JsonContent { - contentType: ContentType.json; - content: unknown; -} - -/** - * Prepend content to content existing under a heading - * - * @category Patch Instructions - */ -export interface PrependHeadingPatchInstruction - extends TextExtendingPatchInstruction, - BaseHeadingPatchInstruction, - StringContent { - operation: "prepend"; -} - -/** - * Append content to content existing under a heading - * - * @category Patch Instructions - */ -export interface AppendHeadingPatchInstruction - extends TextExtendingPatchInstruction, - BaseHeadingPatchInstruction, - StringContent { - operation: "append"; -} - -/** - * Replace content under a heading - * - * @category Patch Instructions - */ -export interface ReplaceHeadingPatchInstruction - extends NonExtendingPatchInstruction, - BaseHeadingPatchInstruction, - StringContent { - operation: "replace"; -} - -/** - * Prepend content to a block referenced by a block reference - * - * @category Patch Instructions - */ -export interface PrependBlockPatchInstruction - extends TextExtendingPatchInstruction, - BaseBlockPatchInstruction, - StringContent { - operation: "prepend"; -} - -/** - * Append content to a block referenced by a block reference - * - * @category Patch Instructions - */ -export interface AppendBlockPatchInstruction - extends TextExtendingPatchInstruction, - BaseBlockPatchInstruction, - StringContent { - operation: "append"; -} - -/** - * Replace content of block referenced by a block reference. - * - * @category Patch Instructions - */ -export interface ReplaceBlockPatchInstruction - extends NonExtendingPatchInstruction, - BaseBlockPatchInstruction, - StringContent { - operation: "replace"; -} - -/** - * Prepend rows to a table referenced by a block reference. - * - * @category Patch Instructions - */ -export interface PrependTableRowsBlockPatchInstruction - extends TextExtendingPatchInstruction, - BaseBlockPatchInstruction, - JsonContent { - operation: "prepend"; -} - -/** - * Patch Instruction for appending rows to a table - * referenced by a block reference. - * - * @category Patch Instructions - */ -export interface AppendTableRowsBlockPatchInstruction - extends TextExtendingPatchInstruction, - BaseBlockPatchInstruction, - JsonContent { - operation: "append"; -} - -/** - * Patch Instruction for replacing all rows of a table - * referenced by a block reference. - * - * @category Patch Instructions - */ -export interface ReplaceTableRowsBlockPatchInstruction - extends NonExtendingPatchInstruction, - BaseBlockPatchInstruction, - JsonContent { - operation: "replace"; -} - -/** - * Prepend content to a frontmatter field - * - * @category Patch Instructions - */ -export interface PrependFrontmatterPatchInstruction - extends NonExtendingPatchInstruction, - BaseFrontmatterPatchInstruction, - JsonContent { - operation: "prepend"; -} - -/** - * Append content to a frontmatter field - * - * @category Patch Instructions - */ -export interface AppendFrontmatterPatchInstruction - extends NonExtendingPatchInstruction, - BaseFrontmatterPatchInstruction, - JsonContent { - operation: "append"; -} - -/** - * Replace content of frontmatter field - * - * @category Patch Instructions - */ -export interface ReplaceFrontmatterPatchInstruction - extends NonExtendingPatchInstruction, - BaseFrontmatterPatchInstruction, - JsonContent { - operation: "replace"; -} - -/** - * Patch Instruction for Headings - */ -export type HeadingPatchInstruction = - | PrependHeadingPatchInstruction - | AppendHeadingPatchInstruction - | ReplaceHeadingPatchInstruction; - -/** - * Patch Instruction for Block References - */ -export type BlockPatchInstruction = - | PrependBlockPatchInstruction - | AppendBlockPatchInstruction - | ReplaceBlockPatchInstruction - | PrependTableRowsBlockPatchInstruction - | AppendTableRowsBlockPatchInstruction - | ReplaceTableRowsBlockPatchInstruction; - -export type FrontmatterPatchInstruction = - | PrependFrontmatterPatchInstruction - | AppendFrontmatterPatchInstruction - | ReplaceFrontmatterPatchInstruction; - -/** - * Patch Instruction - */ -export type PatchInstruction = - | HeadingPatchInstruction - | BlockPatchInstruction - | FrontmatterPatchInstruction; - -export enum ContentType { - /** - * Content is simple markdown text. - */ - text = "text/markdown", - /** - * Content is a JSON document - */ - json = "application/json", -} - -export interface PreprocessedDocument { - frontmatter: Record; - contentOffset: number; - content: string; -} - -export type AppendableFrontmatterType = - | string - | Array - | Record;