diff --git a/README.md b/README.md index 732d84f..6b1290e 100644 --- a/README.md +++ b/README.md @@ -280,14 +280,16 @@ import { buildModel, projectMap } from "markdown-patch"; const map = projectMap(buildModel(document)); // { -// version: "c23234", +// version: "329b63", // frontmatterFields: ["status"], -// headings: [["Meeting Notes"], ["Meeting Notes", "Action Items"]], +// headings: { "Meeting Notes": { "Action Items": {} } }, // blocks: [] // } ``` -Each `headings` entry is an array whose length is that heading's level, so `["Meeting Notes", "Action Items"]` is two deep. Pass one straight back as a `target`. A `null` element marks a skipped level; `""` is a genuinely empty heading. +`headings` is a tree that nests by containment: each heading's text is a key mapping to an object of its child headings, and a leaf heading maps to `{}`. A heading's address is the path of keys from the top level down to it — `["Meeting Notes", "Action Items"]` — which is exactly what you pass back as a `target`; `headingTreePaths(map.headings)` turns the whole tree into that list of addresses for you. + +The tree carries no heading levels, because nesting is by containment rather than by `#`-count: a level skipped in the source (an `h1` followed directly by an `h3`) is not a hole in the tree — the deeper heading is simply a child of the section that contains it. A heading with no text at all is the key `""`. 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.) @@ -427,7 +429,8 @@ The 1.x API spread its addressing across a `::`-joined `target` string with a se | `trimTargetWhitespace` | *(dropped; the engine owns boundary whitespace)* | | `getDocumentMap(doc)` | `projectMap(buildModel(doc))` | -Two behavioral differences to watch for when migrating: +Three 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. +- **The map is a different shape.** `getDocumentMap` returned every heading as a flat `Record` keyed by a `::`-joined string, each entry carrying that heading's source ranges and level. `projectMap` returns addresses only: headings as a [tree nested by containment](#inspecting-a-document), blocks as bare ids, frontmatter as field names — no offsets and no levels. diff --git a/pages/how_to.md b/pages/how_to.md index d884ee4..25c831e 100644 --- a/pages/how_to.md +++ b/pages/how_to.md @@ -133,12 +133,12 @@ const map = projectMap(buildModel(myDocument)); // { // version: "c23234", // frontmatterFields: ["status", "tags"], -// headings: [["Meeting Notes"], ["Meeting Notes", "Action Items"]], +// headings: { "Meeting Notes": { "Action Items": {} } }, // blocks: [] // } ``` -Each `headings` entry can be passed straight back as a `target`, and its length is the heading's level. +`headings` nests by containment: each heading's text maps to an object of its child headings, and a leaf maps to `{}`. The path of keys down to a heading — `["Meeting Notes", "Action Items"]` — is exactly the `target` you pass back, and {@link Reference.headingTreePaths} turns the tree into that list of addresses. # Read a target instead of writing it diff --git a/src/tests/docs.map.test.ts b/src/tests/docs.map.test.ts new file mode 100644 index 0000000..3e9f4b8 --- /dev/null +++ b/src/tests/docs.map.test.ts @@ -0,0 +1,123 @@ +/** + * Pins the map example the README documents under "Inspecting a document", + * along with the claims the prose around it makes. + * + * Two things have to stay true together, so both are checked: the engine has to + * produce the map the docs show, and the docs have to keep showing that map. + * The sample document is read out of README.md itself rather than retyped here, + * so the map under test is the one a reader actually gets by pasting the + * snippet; the expected values are the literal characters the docs display, so + * an engine change fails the projection assertions and a docs edit fails the + * quoted-line assertions. + * + * The example this replaces was wrong for the whole of 2.0: it showed + * `headings` as an array of paths (`[["Meeting Notes"], ["Meeting Notes", + * "Action Items"]]`), a shape no released version ever produced -- 1.x's + * `getDocumentMap` returned a flat `Record` of `::`-joined keys, and 2.0's + * `projectMap` returns a containment tree. A reader following it would have + * indexed into an array that was really an object. + */ + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import { buildModel } from "../model"; +import { projectMap, headingTreePaths } from "../projection"; +import { readTarget } from "../read"; + +const __filename = fileURLToPath(import.meta.url); +const REPO_ROOT = path.join(path.dirname(__filename), "..", ".."); + +const readDoc = (relativePath: string): string => + fs.readFileSync(path.join(REPO_ROOT, relativePath), "utf-8"); + +/** + * The `const document = ...` template literal from the README's "Library usage" + * section -- the document every later snippet, including the map example, is + * written against. Extracted rather than retyped so the projection under test + * is the one a reader gets by copying the snippet. + */ +const readmeSampleDocument = (): string => { + const match = /const document = `([\s\S]*?)`;/.exec(readDoc("README.md")); + if (!match) { + throw new Error( + "README.md no longer defines `const document = ...`; the map example's " + + "input has moved and this test needs to follow it" + ); + } + return match[1]; +}; + +describe("documented map example", () => { + test("the README's sample document projects to the map the README shows", () => { + const map = projectMap(buildModel(readmeSampleDocument())); + + expect(map).toEqual({ + version: "329b63", + frontmatterFields: ["status"], + headings: { "Meeting Notes": { "Action Items": {} } }, + blocks: [], + }); + }); + + test("the README still quotes that map verbatim", () => { + const readme = readDoc("README.md"); + + for (const line of [ + '// version: "329b63",', + '// frontmatterFields: ["status"],', + '// headings: { "Meeting Notes": { "Action Items": {} } },', + "// blocks: []", + ]) { + expect(readme).toContain(line); + } + }); + + test("pages/how_to.md shows the same heading shape", () => { + // The typedoc project document carries its own copy of the example; it + // drifted in lockstep with the README's and has to stay in lockstep now. + expect(readDoc("pages/how_to.md")).toContain( + '// headings: { "Meeting Notes": { "Action Items": {} } },' + ); + }); +}); + +describe("documented claims about the heading tree", () => { + const map = () => projectMap(buildModel(readmeSampleDocument())); + + test("a leaf heading maps to an empty object", () => { + expect(map().headings["Meeting Notes"]["Action Items"]).toEqual({}); + }); + + test("headingTreePaths turns the tree into the list of addresses", () => { + expect(headingTreePaths(map().headings)).toEqual([ + ["Meeting Notes"], + ["Meeting Notes", "Action Items"], + ]); + }); + + test("a path of keys is exactly what a target takes", () => { + // The README's "pass it straight back as a `target`" claim: every address + // the tree advertises has to resolve against the same document. + const document = readmeSampleDocument(); + for (const target of headingTreePaths(map().headings)) { + expect(readTarget(document, { targetType: "heading", target })).toEqual( + expect.objectContaining({ kind: "heading" }) + ); + } + }); + + test("a skipped level is not a hole -- the deeper heading nests under its container", () => { + // "an `h1` followed directly by an `h3`", as the README puts it. + expect(projectMap(buildModel("# One\n\n### Deep\n\nbody\n")).headings).toEqual({ + One: { Deep: {} }, + }); + }); + + test("a heading with no text is the key \"\"", () => { + expect(projectMap(buildModel("# One\n\n##\n\nbody\n")).headings).toEqual({ + One: { "": {} }, + }); + }); +});