41 - a callout title keeps its link targets; Goal 6, what happens is what the audience expects #149

Merged
lilleman merged 4 commits from 41 into main 2026-09-30 19:50:56 +02:00
6 changed files with 67 additions and 45 deletions
Showing only changes of commit 8cecf27757 - Show all commits
+1 -1
View File
@@ -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
+11 -10
View File
@@ -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 | — |
+29 -28
View File
@@ -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: `<http://y>` titles `http://y`, `<a@b.c>`
`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
+21 -1
View File
@@ -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<AdfNode[]>): Result<AdfNode> {
if (!content.ok) return content
@@ -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]- <http://y> or <a@b.c>\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.'))])
})
+2 -5
View File
@@ -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