From 8cecf27757dd54558d1f61c402862b6a49c1c13a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 30 Sep 2026 16:32:13 +0200 Subject: [PATCH] 41 - plainMarkdownToAdf keeps a callout title's link targets; Goal 6, what happens is what the audience expects --- AGENTS.md | 2 +- README.md | 21 +++---- docs/decisions.md | 57 ++++++++++--------- src/markdown/parse/markdown-to-adf.ts | 22 ++++++- .../parse/plain-markdown-to-adf.test.ts | 3 + todo.md | 7 +-- 6 files changed, 67 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a2f7c1..d212a03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,7 +165,7 @@ reading is the ask. Never ask "A or B?": state the gap, the earlier entries of i nearest text, a candidate entry in that file's voice, and the instance it yields. An entry that keeps collecting instances is wrong: rewrite it. -Which output the audience expects — README goal 5 — is settled by a reader panel rather than +Which output the audience expects — README goal 6 — is settled by a reader panel rather than asked: three fresh-context readers, one per README persona the conversion serves, each given only `## Audience` and the input, writing what they expect before picking among outputs the goals allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing diff --git a/README.md b/README.md index be56770..d001777 100644 --- a/README.md +++ b/README.md @@ -42,22 +42,23 @@ In priority order. plain markdown cannot hold — format, design, structure — never content: what a reader of the rendered document sees or follows, its text, images and link targets. Content the document only references is marked where it stood, by a note that reads as the converter's and names what was - left out. What is dropped goes the way the audience expects. The lossy pair creates and - exports; it never saves back over the document it read — identity (task, mention, media ids) - lives only in the lossless pair. -6. **Failures are values.** Nothing throws, and `code` is a closed list — as much a contract as + left out. The lossy pair creates and exports; it never saves back over the document it read — + identity (task, mention, media ids) lives only in the lossless pair. +6. **What happens is what the audience expects.** Where the goals above leave a choice, a + conversion takes the one its audience would predict, reading the input as written. +7. **Failures are values.** Nothing throws, and `code` is a closed list — as much a contract as the emitted formats are. -7. **Nothing in the way.** No runtime dependencies, no I/O, no configuration, no host API: ESM on +8. **Nothing in the way.** No runtime dependencies, no I/O, no configuration, no host API: ESM on any ES2022 engine, in a browser as readily as on a server, installed from public npm. The public surface is the conversions, their types, `isAdfDocument`, and what a consumer needs to check a guarantee this README makes; a helper is exported only when a persona cannot do without it. -8. **Correct before fast.** Each format means what its own specification says — markdown as the +9. **Correct before fast.** Each format means what its own specification says — markdown as the CommonMark spec reads it, well-formed HTML as the HTML standard parses it — both in what this library reads and in what a conforming parser reads from what it writes. A call takes a whole document and returns a whole result. -9. **Fast once correct.** Conversion time grows linearly with the document wherever the goals above - allow it; a faster path that risks one of them is not taken. -10. **Source a contributor can hold.** Any one function reads in one sitting, and no change makes +10. **Fast once correct.** Conversion time grows linearly with the document wherever the goals + above allow it; a faster path that risks one of them is not taken. +11. **Source a contributor can hold.** Any one function reads in one sitting, and no change makes the longest one longer. ## Audience @@ -131,7 +132,7 @@ read replaces mentions, attachments and macros with text. | ADF | Written | Read back | | --- | --- | --- | | `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | `NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error, and Obsidian's: hint tip; success, check, done success; attention warning; danger, failure, fail, missing, bug, error error; any other word info — in any case; the rest of the marker's line is the first paragraph | -| `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word, the rest of the marker's line the title; an expand inside an expand is a `nestedExpand` | +| `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word, the rest of the marker's line the title, a link as `text (target)`, an autolink as its text; an expand inside an expand is a `nestedExpand` | | `taskList` | `- [x] Done`, `- [ ] Todo` | a bullet list whose every item is so marked, `[X]` too | | `backgroundColor` | `==text==` | `==text==` on one line, the text touching both delimiters, bounded outside by whitespace, punctuation or a line edge, or touching a Han, Hangul, Hiragana, Katakana, Thai, Lao, Khmer or Myanmar character on either side, in the editor's default highlight `#f8e6a0` | | `table` | a pipe table: the first row its header, a cell's blocks on one line, a span kept under its header by empty cells | — | diff --git a/docs/decisions.md b/docs/decisions.md index 5fcc49f..f4f02f2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -55,7 +55,7 @@ Where a container's own spelling cannot hold the child it has — a `bulletList` ## Foreign HTML sorts three ways -2026-08-23, the sort 2026-09-20, the maintainer. Goals 1, 3 and 6. Valid while ADF holds no node +2026-08-23, the sort 2026-09-20, the maintainer. Goals 1, 3 and 7. Valid while ADF holds no node for a bare container, a comment or a script. Lands with `todo.md` 6. Every foreign element `htmlToAdf` and `markdownToAdf` read sorts one of three ways, never a silent @@ -76,7 +76,7 @@ than they buy. ## Names stay text -2026-08-23, the maintainer. Goal 7. Valid while resolving a name to an id needs I/O. +2026-08-23, the maintainer. Goal 8. Valid while resolving a name to an id needs I/O. A bare `@name` or `:smile:` in typed text stays a text node. Only directives produce mention/emoji/media nodes; resolving names to ids is the consumer's job. @@ -127,7 +127,7 @@ accepted. ## Plain task ids come from position -2026-09-26, spelling 2026-09-29, the maintainer. Goals 5 and 7. Valid while a site rejects a task +2026-09-26, spelling 2026-09-29, the maintainer. Goals 5 and 8. Valid while a site rejects a task node with no `localId`. `plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId` @@ -139,15 +139,16 @@ ids are only skipped. ## A callout title keeps its link targets -2026-09-29, the maintainer. Goal 5. Valid while an expand's `title` is a string. Lands with -`todo.md` 41. +2026-09-29, the maintainer. Goals 5 and 6. Valid while an expand's `title` is a string. `plainMarkdownToAdf` writes a link in a folded callout's title as its text and its target in -parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. +parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text +is its target, `mailto:` aside, keeps its text alone: `` titles `http://y`, `` +`a@b.c` — three persona readers agreeing, 2026-09-30. ## The plain flavour's spellings -2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goal 5. Valid while GitHub's +2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goals 5 and 6. Valid while GitHub's renderer is the one the audience's markdown is read in. README §Plain markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc, @@ -165,7 +166,7 @@ past `[x]`/`[ ]`, and lifting bare URLs, `@name`, `:shortcode:` or ISO dates int ## The HTML dialect -2026-08-23, the maintainer. Goals 4 and 7. Valid while HTML output is read by consumers styling it +2026-08-23, the maintainer. Goals 4 and 8. Valid while HTML output is read by consumers styling it themselves. The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*` @@ -173,7 +174,7 @@ for what HTML cannot express, text always escaped. No stylesheet ships. ## No runtime dependencies -2026-08-23, the maintainer. Goal 7. Valid while ~20 lines of own code, or a vendored table, do each +2026-08-23, the maintainer. Goal 8. Valid while ~20 lines of own code, or a vendored table, do each job a dependency would. `dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20 @@ -206,7 +207,7 @@ nodes that break it. ## Any ES2022 engine -2026-09-01, the maintainer. Goal 7. Valid while ES2022 is the floor browsers and servers share. +2026-09-01, the maintainer. Goal 8. Valid while ES2022 is the floor browsers and servers share. The library runs on any ES2022 engine, not only Node — a browser as readily as a server. The shipped source is ECMAScript and nothing else: no host import, no host global, no DOM. @@ -220,13 +221,13 @@ never the higher one those repo-only tools want. ## ESM only -2026-08-23, the maintainer. Goal 7. Valid while the audience's toolchains all import ES modules. +2026-08-23, the maintainer. Goal 8. Valid while the audience's toolchains all import ES modules. No CommonJS build, no dual-package hazard. ## One built entrypoint -2026-08-23, the maintainer. Goal 7. Valid while Node refuses to type-strip under `node_modules`. +2026-08-23, the maintainer. Goal 8. Valid while Node refuses to type-strip under `node_modules`. Built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — Node refuses to type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve @@ -234,7 +235,7 @@ an npm consumer. ## Public on npm -2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 7. Valid while the package's source +2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 8. Valid while the package's source stays public beside it. Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public, @@ -243,7 +244,7 @@ for the hub rather than the formats around it. ## The formats are API -2026-08-23, strict input 2026-09-01, content models 2026-09-16, the maintainer. Goals 1 and 6. +2026-08-23, strict input 2026-09-01, content models 2026-09-16, the maintainer. Goals 1 and 7. Valid while consumers store what the library emits. The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing @@ -258,7 +259,7 @@ types in `src/result.ts` hold its shape. ## The code list -2026-08-25, the maintainer; dated below where a rule came later. Goal 6. Valid while a consumer +2026-08-25, the maintainer; dated below where a rule came later. Goal 7. Valid while a consumer switches on `code` with no `default`. - Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose @@ -274,7 +275,7 @@ switches on `code` with no `default`. ## Which code a cause takes -2026-08-28, the maintainer; dated below where a rule came later. Goal 6. Valid while a consumer +2026-08-28, the maintainer; dated below where a rule came later. Goal 7. Valid while a consumer handles one cause alike whichever node, attribute or direction raised it. - A code names the cause; where one cause recurs across node types, across one mark's attributes @@ -307,7 +308,7 @@ handles one cause alike whichever node, attribute or direction raised it. ## `message` and `path` -2026-09-03, the path 2026-09-23, the maintainer. Goals 3 and 6. Valid while a person fixing the +2026-09-03, the path 2026-09-23, the maintainer. Goals 3 and 7. Valid while a person fixing the input reads `message`. - A message names the violation, not the rule alone — a rule by itself states a truth the reader @@ -321,7 +322,7 @@ input reads `message`. ## Publish on a version bump -2026-08-23, converging 2026-09-03, the maintainer. Goal 7. Valid while CI on `main` holds the npm +2026-08-23, converging 2026-09-03, the maintainer. Goal 8. Valid while CI on `main` holds the npm token. `package.json` version on `main` is the source of truth. CI on `main`: tests green and the version @@ -337,7 +338,7 @@ depend on a store that the gate would then have to keep. ## Docs describe the release being built -2026-09-16, the maintainer. Goal 7. Valid while a bump on `main` publishes. +2026-09-16, the maintainer. Goal 8. Valid while a bump on `main` publishes. Docs on `main` describe the release being built rather than the version npm holds, so they match it the moment the bump publishes; add no interim note marking the gap. @@ -353,7 +354,7 @@ nesting a spelling inside its own kind (`*(*a*)*`) names that mark once. ## The gate runs on Deno and Bun -2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 7 and 8. Valid while the library claims +2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 8 and 9. Valid while the library claims any ES2022 engine. The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine @@ -365,7 +366,7 @@ over the corpus rather than over a smoke import. ## The gate installs the tarball -2026-09-03, the maintainer. Goal 7. Valid while consumers install the packed package. +2026-09-03, the maintainer. Goal 8. Valid while consumers install the packed package. The gate packs the build and installs the tarball under `package-tests/`, so `files`, `exports` and `types` are proved on the artifact that ships rather than on the source tree a self-reference @@ -377,7 +378,7 @@ resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` s ## Firefox reads the build -2026-09-04, the maintainer. Goal 7. Valid while the library claims a browser and no other leg runs +2026-09-04, the maintainer. Goal 8. Valid while the library claims a browser and no other leg runs SpiderMonkey. A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and @@ -405,7 +406,7 @@ compared against `undefined` — have a half no valid document reaches. ## The size ratchet -2026-09-20, the maintainer. Goal 10. Valid while no measure picks out what readers find hard +2026-09-20, the maintainer. Goal 11. Valid while no measure picks out what readers find hard better than a function's length. `.oxlintrc.json`'s single rule, over the files `tsconfig.build.json` builds, is a per-function line @@ -429,7 +430,7 @@ markdown, on a fixed seed in the gate; a counterexample found becomes a round-tr ## The CommonMark suite checks three ways -2026-08-27, the maintainer. Goals 1 and 8. Valid while the suite's answers are HTML ADF cannot be +2026-08-27, the maintainer. Goals 1 and 9. Valid while the suite's answers are HTML ADF cannot be compared against. Each example is a named error or markdown that parses and emits to itself byte for byte; its @@ -492,7 +493,7 @@ termination is the loop's own check. ## Readers scan by index -2026-08-30, the kept scan 2026-09-18, the maintainer. Goal 9. Valid while the pipeline persona +2026-08-30, the kept scan 2026-09-18, the maintainer. Goal 10. Valid while the pipeline persona feeds documents nobody typed. A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the line @@ -504,7 +505,7 @@ two cannot disagree — which is what makes the kept value a memo rather than a ## The spelling memo -2026-09-19, the maintainer. Goal 9. Valid while the `commonMarkSpelling` ask spells a node once +2026-09-19, the maintainer. Goal 10. Valid while the `commonMarkSpelling` ask spells a node once per level above it otherwise. The parse and the plain reduction keep each node's readable spelling in a memo, so the @@ -516,7 +517,7 @@ one. Only what succeeded is kept, so no path minted at another position is ever ## Cost fixes are measured, never timed -2026-09-18, the maintainer and the stability-reviewer. Goal 9. Valid while Goal 9 promises growth +2026-09-18, the maintainer and the stability-reviewer. Goal 10. Valid while Goal 10 promises growth rather than a figure. A cost fix that changes no behaviour lands on the suite staying green with no fixture output @@ -535,7 +536,7 @@ refuses it — which is how the whitespace carry finds a line edge. ## Emphasis follows CommonMark's matching -2026-08-27, the maintainer. Goals 1 and 8. Valid while CommonMark's emphasis rules are the +2026-08-27, the maintainer. Goals 1 and 9. Valid while CommonMark's emphasis rules are the reader's. Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 30fee02..84a2ac8 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -130,11 +130,31 @@ function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErro const title = parseInlineContent(line, reading.definitions, path, 'paragraph', 'lossless') if (!title.ok) return title if (title.value.image !== undefined) return failure('unmappable-image', imageOnMarkerLine, path) - const text = title.value.nodes.map((node) => node.text ?? '').join('') + const text = titleText(title.value.nodes) const type = reading.inExpand ? 'nestedExpand' : 'expand' return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readMarked(paragraphsOf(led.position, rest), false, body, { ...reading, inExpand: true }, path, depth)) } +// docs/decisions.md, A callout title keeps its link targets. +function titleText(nodes: readonly AdfNode[]): string { + let text = '' + let linked = '' + for (const [index, node] of nodes.entries()) { + const href = linkTarget(node) + text += node.text ?? '' + linked += href === undefined ? '' : node.text ?? '' + if (href === undefined || linkTarget(nodes[index + 1]) === href) continue + if (href !== linked && href !== `mailto:${linked}`) text += ` (${href})` + linked = '' + } + return text +} + +function linkTarget(node: AdfNode | undefined): string | undefined { + const href = node?.marks?.find((mark) => mark.type === 'link')?.attrs?.href + return typeof href === 'string' ? href : undefined +} + // Atlassian's schema requires a panel and an expand to hold a block. function filledNode(node: AdfNode, content: Result): Result { if (!content.ok) return content diff --git a/src/markdown/parse/plain-markdown-to-adf.test.ts b/src/markdown/parse/plain-markdown-to-adf.test.ts index 4a7d8c7..3c0e112 100644 --- a/src/markdown/parse/plain-markdown-to-adf.test.ts +++ b/src/markdown/parse/plain-markdown-to-adf.test.ts @@ -120,6 +120,9 @@ test('reads a folded callout to an expand titled by the rest of its marker line, node('expand', { title: 'Why?' }, paragraph(text('See '), text('the docs', link), text(', '), text('now', strong), text('.'))), ]) assert.deepEqual(read('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))]) + assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), normal(node('expand', { title: 'See x (http://y)' }, paragraph()))) + assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), normal(node('expand', { title: 'a bc (u) and d (v)' }, paragraph()))) + assert.deepEqual(read('> [!faq]- or \n'), normal(node('expand', { title: 'http://y or a@b.c' }, paragraph()))) assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), normal(node('expand', { title: 'Set ==x== here' }, paragraph()))) assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))]) }) diff --git a/todo.md b/todo.md index 9f43c60..60724b2 100644 --- a/todo.md +++ b/todo.md @@ -2,9 +2,6 @@ ## 0.2.0 -- **41 — Keep a link's target when `plainMarkdownToAdf` reads a callout title.** Per - `docs/decisions.md` §A callout title keeps its link targets; today `> [!faq]- See [x](http://y)` - reads to an expand titled `See x`, the target gone. - **40 — Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document it takes.** Today it holds for editor-normal documents only: two adjacent text nodes with the same marks merge, an empty `attrs`, `marks` or `content` drops, and `-0` reads back `0` — shapes pipelines @@ -29,11 +26,11 @@ measured one. - **33 — Make a carried mark run cost the line one re-emit.** `adfToMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking cannot spell re-emits the whole line - before riding the carry, quadratic in the runs (Goal 9), and the plain reduction's + before riding the carry, quadratic in the runs (Goal 10), and the plain reduction's `spellableLine` drops one mark per re-emit the same way. Make both linear. - **42 — Trim a text leaf's trailing blanks in linear time.** `plain-inline.ts`'s `leafEdges` finds the trail with an unanchored `/[ \t]*$/`, quadratic in a run of blanks inside one leaf: a - paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in `adfToPlainMarkdown` (Goal 9). Scan backward, + paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in `adfToPlainMarkdown` (Goal 10). Scan backward, as the expand title's trim does. - **34 — Read emphasis flanking by the whole character beside an astral symbol.** Check whether `line-escaping.ts`'s `charAt` and the parser's flanking read one UTF-16 unit beside an astral