17 Commits

Author SHA1 Message Date
lilleman 026ea5e1b6 Rework the roadmap: fold perf and docs fixes into 0.2.0, add 0.2.1
CI / gate (push) Successful in 18s
CI / publish (push) Successful in 4s
2026-09-05 18:14:05 +02:00
lilleman 5d19bdbae7 Add the online sandbox, lossy conversion and @atlaskit/adf-schema evaluation
CI / gate (push) Successful in 17s
CI / publish (push) Successful in 3s
2026-09-05 16:41:34 +02:00
lilleman 75f35eef0b Tick the 0.1.0 release and record the publish token's deadline
CI / gate (push) Successful in 18s
CI / publish (push) Successful in 3s
2026-09-05 14:16:29 +02:00
lilleman 8a847dea0a Record the gate's silence as a 0.1.1 item
CI / gate (push) Successful in 1m17s
CI / publish (push) Successful in 10s
2026-09-04 12:24:50 +02:00
lilleman bcab476c87 Release 0.1.0
CI / gate (push) Successful in 5m18s
CI / publish (push) Failing after 5m10s
2026-09-04 12:07:41 +02:00
lilleman 197dee199b Merge pull request 'Name the tag-push permission and the one-commit bump in the release checklist' (#50) from release-checklist into main
CI / gate (push) Successful in 19s
CI / publish (push) Has been skipped
2026-09-04 11:26:33 +02:00
lilleman 611336bb33 Name the tag-push permission and the one-commit bump in the release checklist
CI / gate (push) Successful in 2m22s
CI / publish (push) Has been skipped
2026-09-04 11:20:29 +02:00
lilleman 0523bb8182 Merge pull request '5d: the browser leg' (#49) from browser-leg into main
CI / publish (push) Waiting to run
CI / gate (push) Successful in 2m22s
2026-09-04 11:19:39 +02:00
lilleman 4d13583d6e 5d: re-raise the interrupt so a signalled gate does not exit 0
CI / gate (push) Successful in 5m19s
CI / publish (push) Has been skipped
2026-09-04 11:13:42 +02:00
lilleman 9e688f4b00 5d: the review's image swap, teardown trap and fixture-name assertions
CI / gate (push) Successful in 1m25s
CI / publish (push) Has been skipped
2026-09-04 10:57:35 +02:00
lilleman edf11304e3 5d: the browser leg
CI / gate (push) Successful in 5m27s
CI / publish (push) Has been skipped
2026-09-04 09:49:44 +02:00
lilleman e9a70ac8f6 Merge pull request '5c: the build and the release pipeline' (#48) from release-pipeline into main
CI / gate (push) Successful in 5m13s
CI / publish (push) Successful in 2s
2026-09-04 09:24:37 +02:00
lilleman b58f51f846 5c: keep the mark nesting explanation in one place
CI / gate (push) Successful in 5m13s
CI / publish (push) Has been skipped
2026-09-04 09:19:09 +02:00
lilleman 7612feb75e 5c: install the packed artifact offline so the gate needs no registry
CI / gate (push) Successful in 42s
CI / publish (push) Has been skipped
2026-09-04 09:03:31 +02:00
lilleman 67df1e2f4b 5c: match the mark set's nesting on both sides of the round-trip
CI / gate (push) Successful in 8m35s
CI / publish (push) Has been skipped
2026-09-04 08:53:00 +02:00
lilleman 0dfcc0f9ca 5c: report the depth cause from the guards, and make the publish converge
CI / gate (push) Successful in 7m12s
CI / publish (push) Has been skipped
2026-09-03 21:19:01 +02:00
lilleman d9056973a1 5c: the build, the freeze audit and the release pipeline
CI / gate (push) Successful in 11s
CI / publish (push) Has been skipped
2026-09-03 20:26:25 +02:00
32 changed files with 730 additions and 118 deletions
+13
View File
@@ -7,3 +7,16 @@ jobs:
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
- run: bash ci.sh - run: bash ci.sh
publish:
concurrency:
cancel-in-progress: false
group: publish
if: github.ref == 'refs/heads/main'
needs: gate
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
- env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: bash publish.sh
+44 -5
View File
@@ -16,7 +16,9 @@ less silently destroys content an editor could not represent, in a document it d
When losslessness and readability conflict, losslessness wins. When losslessness and readability conflict, losslessness wins.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
back yields the library's canonical spelling, and that spelling round-trips byte-identically. back yields the library's canonical spelling, and that spelling round-trips byte-identically
where there is a way back. CommonMark spells link destinations the flavour has no escape for, so a
parse succeeding does not imply a spellable document; `todo.md` 3k's exception list names those.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks "Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
merged, JSON number semantics, an empty attrs object, marks array or content array the absent merged, JSON number semantics, an empty attrs object, marks array or content array the absent
@@ -62,8 +64,8 @@ they never reach a consumer.
- Runs on any ES2022 engine, not only Node — a browser as readily as a server. The shipped source - 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. `tsconfig.build.json` is is ECMAScript and nothing else: no host import, no host global, no DOM. `tsconfig.build.json` is
that gate, typechecking the shipped files alone, so `node:fs`, `process` and an ES2024 method are that gate, typechecking and emitting the shipped files alone, so `node:fs`, `process` and an
compile errors here rather than a consumer's crash there. The standard is the line, never an ES2024 method are compile errors here rather than a consumer's crash there. The standard is the line, never an
engine list: one implementing it in part — Hermes is the live doubt, on §10's property escapes engine list: one implementing it in part — Hermes is the live doubt, on §10's property escapes
and on lookbehind — is out of scope rather than a bug. Node's test runner, the corpus reads and and on lookbehind — is out of scope rather than a bug. Node's test runner, the corpus reads and
the build are the repo's own, the build are the repo's own,
@@ -129,7 +131,17 @@ descends, so a document reports its first error in document order. `not-an-adf-d
the document's own path throughout: eight of the guard's nine branches read the document's own the document's own path throughout: eight of the guard's nine branches read the document's own
shape, and threading a path to the ninth — a malformed node anywhere in the tree — wants the shape, and threading a path to the ninth — a malformed node anywhere in the tree — wants the
manual stack §11's no-recursion rule forces, whose empty half no input reaches. The message names manual stack §11's no-recursion rule forces, whose empty half no input reaches. The message names
the violation instead. the violation instead. Depth is not one of the nine: `adfDocumentFault` returns the code with the
message, so an attribute value past 500 levels is `unsupported-nesting-depth` from the emitter as
it already is from the parser, both directions refusing the same value. A node's attribute is
counted from the value itself, never from the `attrs` object holding it; a mark's is counted three
levels in, because the block directive spells the whole mark set as one JSON attribute and the
parser reads the value at the bottom of array, mark and `attrs`. `isAdfDocument` is true for a depth fault:
a deep document is a document, as the 2000-level blocks and the 600-deep marks the guard already
waves through are, and depth is the walks' answer rather than the shape's. A non-finite number
stays parted where depth is joined: the parse says `unsupported-node-shape` because the markdown is
at fault, the emit `not-an-adf-document` because the input is, and unlike depth nothing round-trips
inconsistently between them.
`position` is the parse side's alone: an emitter reads no source, so an emit error carries `path` `position` is the parse side's alone: an emitter reads no source, so an emit error carries `path`
and nothing more. It is `{ line, offset }` at the start of the line the block holding the refusal and nothing more. It is `{ line, offset }` at the start of the line the block holding the refusal
@@ -148,7 +160,16 @@ wide `Result<T>`, since half their refusals come from an emit stage that read no
- `package.json` version on `main` is the source of truth. CI on `main`: tests green and version - `package.json` version on `main` is the source of truth. CI on `main`: tests green and version
differs from npm → publish and tag `vX.Y.Z`. No bump, no deploy; the bump is each shipping PR's differs from npm → publish and tag `vX.Y.Z`. No bump, no deploy; the bump is each shipping PR's
deliberate semver judgment. deliberate semver judgment. `publish.sh` is that job, and `private: true` stops it before it
reads the token, so the pipeline is live and silent until the maintainer's first bump drops the
field.
- The publish and the tag each observe their own end state — the version on npm, the tag on the
remote — and neither gates the other, so a run that dies between them converges on the next push
to `main` rather than leaving npm ahead of the tags. An unanswered registry reads the same as an
unpublished version, which npm's own duplicate rejection is what catches. The job rebuilds rather
than taking the gate's `dist`: the lockfile is committed, the image is patch-pinned and `tsc` is
deterministic, so the two builds agree, and promoting an artifact would make the release path
depend on a store that the gate would then have to keep.
- Renovate watches devDependencies, Docker pins and action tags; automerges everything on green CI. - Renovate watches devDependencies, Docker pins and action tags; automerges everything on green CI.
- Docker images pin the full patch version (`node:24.19.0-alpine3.24`, never `node:24`), as - Docker images pin the full patch version (`node:24.19.0-alpine3.24`, never `node:24`), as
specific as the publisher tags: `oven/bun:1.4.0-alpine` pins Bun's patch and leaves the base specific as the publisher tags: `oven/bun:1.4.0-alpine` pins Bun's patch and leaves the base
@@ -168,6 +189,24 @@ emphasis matching leans on can disagree. Both refuse a run matching no test, so
vacuous-green guard, and a test may reach only for what all three `node:` shims carry — the price vacuous-green guard, and a test may reach only for what all three `node:` shims carry — the price
of proving those engines over the corpus rather than over a smoke import. of proving those engines over the corpus rather than over a smoke import.
The gate then 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 would resolve against. `consumer.ts` typechecks the emitted `.d.ts` from outside
`tsconfig.build.json` — declaration emit leaves the `.ts` specifiers
`rewriteRelativeImportExtensions` rewrites in the JavaScript, and this is what says a consumer's
resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` stays unproven.
`node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor.
A fourth engine reads the build rather than the source: a headless Firefox loads `dist/index.js`
over HTTP and converts the whole corpus, which is §6's browser half and the only SpiderMonkey
there is — the gate's other three engines are two V8s and a JavaScriptCore that is not Safari's.
A WebDriver session is what carries a verdict back out, the driver and the page's server sharing
one network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such
channel, and loading `dist/index.js` in a globals-stripped realm buys one by not running a browser.
The leg re-checks the conversions and nothing else — each fixture's emitted markdown, its parsed
document, its error code — leaving the corpus's pairing, uniqueness, source positions and
byte-level equality to the Node suite that owns them.
The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index `noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
+8 -5
View File
@@ -3,8 +3,8 @@
Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and
an HTML dialect. an HTML dialect.
**Status: pre-release — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML not **Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
yet.** `0.3.0`.**
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar: Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
[`spec/flavour.md`](spec/flavour.md). [`spec/flavour.md`](spec/flavour.md).
@@ -101,7 +101,7 @@ emit refuses:
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span | | `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
| `unspellable-link` | a link `href` or `title` holds what no canonical escape spells — a backslash, a newline, a control character, an entity reference, an angle bracket beside a space | percent-encode the destination (`%5C` for the backslash, `%26` for the `&` that opens the entity), or drop the title | | `unspellable-link` | a link `href` or `title` holds what no canonical escape spells — a backslash, a newline, a control character, an entity reference, an angle bracket beside a space | percent-encode the destination (`%5C` for the backslash, `%26` for the `&` that opens the entity), or drop the title |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines | | `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
| `unsupported-nesting-depth` | blocks, marks or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take — or markdown writes as a directive a node the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body | | `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take — or markdown writes as a directive a node the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body |
## The guarantees ## The guarantees
@@ -112,7 +112,9 @@ emit refuses:
carve-outs — literal text matching directive, pipe-table or strikethrough syntax is claimed carve-outs — literal text matching directive, pipe-table or strikethrough syntax is claimed
(escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as its own (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as its own
title-less paragraph; mid-text and titled images are error results. Converting back yields the title-less paragraph; mid-text and titled images are error results. Converting back yields the
library's canonical spelling, which round-trips byte-identically. library's canonical spelling, which round-trips byte-identically — where it converts back at
all: a parse succeeding is no promise of that, so keep the source until the way back succeeds.
`[a](/a\b)`, `<http://x?a=1&amp;b=2>` and `[a](/x&#10;y)` read cleanly and then refuse.
- Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a - Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a
processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.3.0`. processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.3.0`.
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding - Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
@@ -146,4 +148,5 @@ Personas, never named consumers (AGENTS.md §7):
ESM only, no runtime dependencies, public npmjs. Built JavaScript with `.d.ts` beside it. ESM only, no runtime dependencies, public npmjs. Built JavaScript with `.d.ts` beside it.
Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node, Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node,
Deno and Bun. Contract: `AGENTS.md` §56. Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
Contract: `AGENTS.md` §56.
+13
View File
@@ -0,0 +1,13 @@
try {
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
window.convertCorpus = (corpus) => ({
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
const adf = JSON.parse(json)
return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: markdownToAdf(markdown) }
}),
})
} catch (cause) {
window.adfCodecFault = `/dist/index.js did not load: ${cause}`
}
+4
View File
@@ -0,0 +1,4 @@
<!doctype html>
<meta charset="utf-8">
<title>@larvit/adf-codec in a browser</title>
<script type="module" src="/browser-tests/convert-corpus.js"></script>
+129
View File
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { createServer } from 'node:http'
import { extname, join } from 'node:path'
const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
const driver = 'http://127.0.0.1:4444'
const repoRoot = join(import.meta.dirname, '..')
const corpusRoot = join(repoRoot, 'corpus')
const corpusEntries = readdirSync(corpusRoot, { encoding: 'utf8', recursive: true })
function checking(name, result, assertions) {
assert.equal(result?.name, name, `the browser answered ${JSON.stringify(result?.name)} where ${name} was sent`)
try {
assertions()
} catch (cause) {
throw new Error(`${name} in the browser — ${cause.message}`, { cause })
}
}
async function command(method, path, body) {
const response = await fetch(`${driver}${path}`, {
body: body === undefined ? undefined : JSON.stringify(body),
headers: { 'content-type': 'application/json' },
method,
signal: AbortSignal.timeout(180_000),
})
const payload = await response.json()
if (!response.ok) throw new Error(`webdriver ${method} ${path}: ${JSON.stringify(payload.value)}`)
return payload.value
}
function fileBody(file) {
try {
return readFileSync(file)
} catch {
return undefined
}
}
function fixture(name, extension) {
return readFileSync(join(corpusRoot, `${name}${extension}`), 'utf8')
}
function fixtureNames(kind, extension) {
return corpusEntries
.filter((name) => name.startsWith(`${kind}/`) && name.endsWith(extension))
.map((name) => name.slice(0, -extension.length))
.sort()
}
function refusal(result) {
return result.ok ? '' : `${result.error.code}: ${result.error.message}`
}
async function whenDriverAnswers() {
const deadline = Date.now() + 60_000
for (;;) {
const status = await command('GET', '/status').catch(() => undefined)
if (status?.ready === true) return
if (Date.now() > deadline) throw new Error(`no WebDriver answered ${driver}/status within 60s`)
await new Promise((resolve) => setTimeout(resolve, 200))
}
}
const corpus = {
errors: fixtureNames('errors', '.md').map((name) => ({ markdown: fixture(name, '.md'), name })),
normalization: fixtureNames('normalization', '.md').map((name) => ({ markdown: fixture(name, '.md'), name })),
roundTrip: fixtureNames('round-trip', '.json').map((name) => ({ json: fixture(name, '.json'), markdown: fixture(name, '.md'), name })),
}
assert.deepEqual(
readdirSync(corpusRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort(),
['errors', 'normalization', 'round-trip'],
'a corpus kind the browser leg does not convert',
)
for (const [kind, fixtures] of Object.entries(corpus)) assert.ok(fixtures.length > 0, `the browser leg found no ${kind} fixture to convert`)
const server = createServer((request, response) => {
const file = join(repoRoot, new URL(request.url ?? '/', 'http://127.0.0.1').pathname)
const body = file.startsWith(repoRoot) ? fileBody(file) : undefined
if (body === undefined) response.writeHead(404).end()
else response.writeHead(200, { 'content-type': contentTypes[extname(file)] ?? 'application/octet-stream' }).end(body)
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
await whenDriverAnswers()
const session = await command('POST', '/session', {
capabilities: { alwaysMatch: { browserName: 'firefox', 'moz:firefoxOptions': { args: ['-headless'] }, timeouts: { pageLoad: 60_000, script: 120_000 } } },
})
await command('POST', `/session/${session.sessionId}/url`, { url: `http://127.0.0.1:${server.address().port}/browser-tests/page.html` })
const results = await command('POST', `/session/${session.sessionId}/execute/sync`, {
args: [corpus],
script: 'if (!window.convertCorpus) throw new Error(window.adfCodecFault ?? "the page defined no convertCorpus"); return window.convertCorpus(arguments[0])',
})
server.close()
for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) {
const result = results.roundTrip[index]
checking(name, result, () => {
assert.ok(result.isDocument, `${name}.json is no ADF document`)
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.equal(result.emitted.value, markdown)
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.deepEqual(result.parsed.value, JSON.parse(json))
})
}
for (const [index, { name }] of corpus.normalization.entries()) {
const result = results.normalization[index]
checking(name, result, () => {
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.deepEqual(result.parsed.value, JSON.parse(fixture(name, '.json')))
})
}
for (const [index, { name }] of corpus.errors.entries()) {
const result = results.errors[index]
checking(name, result, () => {
assert.ok(!result.parsed.ok, `it was not refused, it built ${JSON.stringify(result.parsed.value)}`)
assert.equal(result.parsed.error.code, fixture(name, '.error').trimEnd())
})
}
console.log(
`Firefox ${session.capabilities.browserVersion} converted ${corpus.roundTrip.length} round-trip, ${corpus.normalization.length} normalization and ${corpus.errors.length} error fixtures`,
)
+11 -9
View File
@@ -1,15 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
source ./docker-runner.sh
bun_image=oven/bun:1.4.0-alpine
deno_image=denoland/deno:2.9.6
node_image=node:24.19.0-alpine3.24
in_image() {
local image=$1 entrypoint=$2
shift 2
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
}
in_image "$node_image" npm ci in_image "$node_image" npm ci
in_image "$node_image" npm run typecheck in_image "$node_image" npm run typecheck
@@ -26,3 +18,13 @@ fi
in_image "$deno_image" deno test --allow-read --no-check src/ in_image "$deno_image" deno test --allow-read --no-check src/
in_image "$bun_image" bun test src/ in_image "$bun_image" bun test src/
in_image "$node_image" npm run build
in_image "$node_image" sh -c 'set -e
rm -rf package-tests/node_modules
npm pack --pack-destination /tmp >/dev/null
npm install --no-audit --no-fund --no-package-lock --no-save --offline --prefix package-tests /tmp/*.tgz >/dev/null'
in_image "$node_image" npx tsc -p package-tests
in_image "$floor_image" node package-tests/node-floor.js
with_firefox in_image "$node_image" node browser-tests/run.js
@@ -0,0 +1 @@
unsupported-nesting-depth
+2
View File
@@ -0,0 +1,2 @@
:::tableCell {colwidth="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"}
:::
+23
View File
@@ -0,0 +1,23 @@
bun_image=oven/bun:1.4.0-alpine
deno_image=denoland/deno:2.9.6
firefox_image=selenium/standalone-firefox:153.0.4
floor_image=node:18.20.8-alpine3.21
node_image=node:24.19.0-alpine3.24
in_image() {
local image=$1 entrypoint=$2
shift 2
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
}
with_firefox() {
local container in_image_network status=0
container=$(docker run -d --rm "$firefox_image")
# The id is baked in: the trap fires after this function's locals are gone.
trap "docker rm -f $container >/dev/null 2>&1" EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
in_image_network="container:$container"
"$@" || status=$?
return $status
}
+3 -3
View File
@@ -1,19 +1,19 @@
{ {
"name": "@larvit/adf-codec", "name": "@larvit/adf-codec",
"version": "0.0.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@larvit/adf-codec", "name": "@larvit/adf-codec",
"version": "0.0.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "24.13.3", "@types/node": "24.13.3",
"typescript": "7.0.2" "typescript": "7.0.2"
}, },
"engines": { "engines": {
"node": ">=24" "node": ">=18"
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
+11
View File
@@ -0,0 +1,11 @@
import { adfToMarkdown, isAdfDocument, markdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
const emitted: Result<string> = adfToMarkdown(document)
const parsed: Result<AdfDocument, ParseError> = markdownToAdf('x\n')
const guarded: boolean = isAdfDocument(document)
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line
export const surface = { code, guarded, line }
+9
View File
@@ -0,0 +1,9 @@
import assert from 'node:assert/strict'
import { adfToMarkdown, markdownToAdf } from '@larvit/adf-codec'
const document = { content: [{ content: [{ marks: [{ type: 'em' }], text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
const emitted = adfToMarkdown(document)
assert.ok(emitted.ok, emitted.ok ? '' : emitted.error.message)
assert.deepEqual(markdownToAdf(emitted.value), { ok: true, value: document })
+5
View File
@@ -0,0 +1,5 @@
{
"name": "adf-codec-package-tests",
"private": true,
"type": "module"
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"target": "ES2022",
"types": []
},
"include": ["consumer.ts"]
}
+12 -2
View File
@@ -1,7 +1,6 @@
{ {
"name": "@larvit/adf-codec", "name": "@larvit/adf-codec",
"version": "0.0.0", "version": "0.1.0",
"private": true,
"description": "Lossless conversion between Atlassian Document Format, extended markdown and HTML", "description": "Lossless conversion between Atlassian Document Format, extended markdown and HTML",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
@@ -9,10 +8,21 @@
"url": "git+https://gitea.larvit.se/larvit/adf-codec.git" "url": "git+https://gitea.larvit.se/larvit/adf-codec.git"
}, },
"type": "module", "type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"sideEffects": false,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json" "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json"
}, },
Executable
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
source ./docker-runner.sh
read_field() {
in_image "$node_image" npm pkg get "$1" | tr -d '"\r'
}
published_version() {
in_image "$node_image" npm view "$1@$2" version 2>/dev/null || true
}
private=$(read_field private)
if [ "$private" = 'true' ]; then
echo 'package.json is private — the maintainer removes that in the bump that first publishes'
exit 0
fi
name=$(read_field name)
version=$(read_field version)
published=$(published_version "$name" "$version")
tagged=$(git ls-remote --tags origin "v$version")
# Both steps observe their own end state, so a partial run converges on the next push to main.
if [ -z "$published" ]; then
: "${NPM_TOKEN:?the publish needs NPM_TOKEN}"
in_image "$node_image" npm ci
in_image "$node_image" npm run build
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e NPM_TOKEN -v "$PWD:/app" -w /app --entrypoint sh "$node_image" -c \
'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public'
fi
if [ -z "$tagged" ]; then
git tag "v$version"
git push origin "v$version"
fi
+34 -8
View File
@@ -6,8 +6,8 @@
"customType": "regex", "customType": "regex",
"datasourceTemplate": "docker", "datasourceTemplate": "docker",
"depNameTemplate": "denoland/deno", "depNameTemplate": "denoland/deno",
"description": "Pin the Deno image ci.sh runs", "description": "Pin the Deno image the gate runs",
"managerFilePatterns": ["ci.sh"], "managerFilePatterns": ["docker-runner.sh"],
"matchStrings": ["denoland/deno:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["denoland/deno:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker" "versioningTemplate": "docker"
}, },
@@ -15,17 +15,27 @@
"customType": "regex", "customType": "regex",
"datasourceTemplate": "docker", "datasourceTemplate": "docker",
"depNameTemplate": "node", "depNameTemplate": "node",
"description": "Pin the node image ci.sh runs", "description": "Pin the node image the gate runs",
"managerFilePatterns": ["ci.sh"], "managerFilePatterns": ["docker-runner.sh"],
"matchStrings": ["node:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["node_image=node:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker"
},
{
"customType": "regex",
"datasourceTemplate": "docker",
"depNameTemplate": "node-floor",
"description": "Pin the node image proving engines.node, held to that major",
"managerFilePatterns": ["docker-runner.sh"],
"matchStrings": ["floor_image=node:(?<currentValue>[0-9][^\\s\"']*)"],
"packageNameTemplate": "node",
"versioningTemplate": "docker" "versioningTemplate": "docker"
}, },
{ {
"customType": "regex", "customType": "regex",
"datasourceTemplate": "docker", "datasourceTemplate": "docker",
"depNameTemplate": "oven/bun", "depNameTemplate": "oven/bun",
"description": "Pin the Bun image ci.sh runs", "description": "Pin the Bun image the gate runs",
"managerFilePatterns": ["ci.sh"], "managerFilePatterns": ["docker-runner.sh"],
"matchStrings": ["oven/bun:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["oven/bun:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker" "versioningTemplate": "docker"
}, },
@@ -37,7 +47,23 @@
"managerFilePatterns": [".gitea/workflows/renovate.yml"], "managerFilePatterns": [".gitea/workflows/renovate.yml"],
"matchStrings": ["renovate/renovate:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["renovate/renovate:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker" "versioningTemplate": "docker"
},
{
"customType": "regex",
"datasourceTemplate": "docker",
"depNameTemplate": "selenium/standalone-firefox",
"description": "Pin the Firefox image the gate's browser leg runs",
"managerFilePatterns": ["docker-runner.sh"],
"matchStrings": ["selenium/standalone-firefox:(?<currentValue>[0-9][^\\s\"']*)"],
"versioningTemplate": "docker"
} }
], ],
"extends": ["config:recommended"] "extends": ["config:recommended"],
"packageRules": [
{
"allowedVersions": "<19",
"description": "engines.node states >=18, so the image proving it stays on 18",
"matchDepNames": ["node-floor"]
}
]
} }
+30 -1
View File
@@ -1,10 +1,26 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { JsonValue } from '../json-value.ts'
import { adfDocumentFault, isAdfDocument } from './document.ts' import { adfDocumentFault, isAdfDocument } from './document.ts'
import { largestNesting } from '../nesting.ts'
function fault(value: unknown): string { function fault(value: unknown): string {
return adfDocumentFault(value) ?? 'accepted' return adfDocumentFault(value)?.message ?? 'accepted'
}
function faultCode(value: unknown): string {
return adfDocumentFault(value)?.code ?? 'accepted'
}
function nested(levels: number): JsonValue {
let value: JsonValue = 1
for (let level = 0; level < levels; level += 1) value = [value]
return value
}
function withAttribute(value: JsonValue): unknown {
return { content: [{ attrs: { a: value }, type: 'paragraph' }], type: 'doc', version: 1 }
} }
test('accepts an editor-normal document', () => { test('accepts an editor-normal document', () => {
@@ -44,6 +60,19 @@ test('rejects a node whose shape ProseMirror JSON cannot hold', () => {
assert.equal(isAdfDocument({ content: [{ attrs: [], type: 'paragraph' }], type: 'doc', version: 1 }), false) assert.equal(isAdfDocument({ content: [{ attrs: [], type: 'paragraph' }], type: 'doc', version: 1 }), false)
}) })
test('names the attribute nesting past the levels the parser reads one at, and still calls the value a document', () => {
const deeper = (key: string, type: string, levels: number = largestNesting): string =>
`the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted')
assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph'))
assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth')
assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true)
const marked = (levels: number): unknown => ({ content: [{ marks: [{ attrs: { a: nested(levels) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 })
assert.equal(fault(marked(largestNesting - 3)), 'accepted')
assert.equal(fault(marked(largestNesting - 2)), deeper('a', 'link', largestNesting - 3))
assert.equal(isAdfDocument(marked(largestNesting - 2)), true)
})
test('accepts the JSON values an attribute may hold', () => { test('accepts the JSON values an attribute may hold', () => {
assert.equal(isAdfDocument({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true) assert.equal(isAdfDocument({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true)
assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false) assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false)
+57 -13
View File
@@ -1,4 +1,6 @@
import { isJsonValue, type JsonValue } from '../json-value.ts' import type { ConvertFault } from '../result.ts'
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
export type AdfAttributes = { [key: string]: JsonValue } export type AdfAttributes = { [key: string]: JsonValue }
@@ -21,23 +23,32 @@ export type AdfDocument = {
version: number version: number
} }
// A block directive spells the whole mark set as one JSON attribute, so a mark's value sits three levels inside it.
const markAttributeNesting = largestNesting - 3
const documentKeys = ['content', 'type', 'version'] const documentKeys = ['content', 'type', 'version']
const markKeys = ['attrs', 'type'] const markKeys = ['attrs', 'type']
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type'] const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
export function adfDocumentFault(value: unknown): string | undefined { export function adfDocumentFault(value: unknown): ConvertFault | undefined {
if (!isRecord(value)) return `an ADF document is an object: found ${describe(value)}` if (!isRecord(value)) return notADocument(`an ADF document is an object: found ${describe(value)}`)
const extra = extraKey(value, documentKeys) const extra = extraKey(value, documentKeys)
if (extra !== undefined) return `an ADF document holds content, type and version alone: found the key ${extra}` if (extra !== undefined) return notADocument(`an ADF document holds content, type and version alone: found the key ${extra}`)
if (!('type' in value)) return 'an ADF document holds type "doc": found no type field' if (!('type' in value)) return notADocument('an ADF document holds type "doc": found no type field')
if (value['type'] !== 'doc') return `an ADF document holds type "doc": found ${describe(value['type'])}` if (value['type'] !== 'doc') return notADocument(`an ADF document holds type "doc": found ${describe(value['type'])}`)
if (!('version' in value)) return 'an ADF document holds a version number: found no version field' if (!('version' in value)) return notADocument('an ADF document holds a version number: found no version field')
const version = value['version'] const version = value['version']
if (typeof version !== 'number' || !Number.isFinite(version)) return `an ADF document holds a version number: found ${describe(version)}` if (typeof version !== 'number' || !Number.isFinite(version)) return notADocument(`an ADF document holds a version number: found ${describe(version)}`)
if (!('content' in value)) return undefined if (!('content' in value)) return undefined
const content = value['content'] const held: unknown = value['content']
if (!Array.isArray(content)) return `an ADF document's content is an array: found ${describe(content)}` if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`)
return isNodeArray(content) ? undefined : "an ADF document's content holds ADF nodes: one of them is not" const content: readonly unknown[] = held
if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not")
return nestingFault(content)
}
export function attributeNestingMessage(key: string, type: string, levels: number = largestNesting): string {
return `the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
} }
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
@@ -45,8 +56,10 @@ export function carriesOnly(node: AdfNode, attributes: readonly string[]): boole
return holdsOnly(node.attrs ?? {}, attributes) return holdsOnly(node.attrs ?? {}, attributes)
} }
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
export function isAdfDocument(value: unknown): value is AdfDocument { export function isAdfDocument(value: unknown): value is AdfDocument {
return adfDocumentFault(value) === undefined const fault = adfDocumentFault(value)
return fault === undefined || fault.code === 'unsupported-nesting-depth'
} }
export function isAdfNode(value: unknown): value is AdfNode { export function isAdfNode(value: unknown): value is AdfNode {
@@ -59,7 +72,7 @@ export function isAdfMark(value: unknown): value is AdfMark {
return !('attrs' in value) || isAttributes(value['attrs']) return !('attrs' in value) || isAttributes(value['attrs'])
} }
function isNodeArray(value: readonly unknown[]): boolean { function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
const pending: unknown[] = [...value] const pending: unknown[] = [...value]
while (pending.length > 0) { while (pending.length > 0) {
const node = pending.pop() const node = pending.pop()
@@ -77,6 +90,33 @@ function isNodeArray(value: readonly unknown[]): boolean {
return true return true
} }
function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
const pending: AdfNode[] = [...nodes]
while (pending.length > 0) {
const node = pending.pop()
if (node === undefined) continue
const fault = attributesFault(node.attrs, node.type) ?? marksFault(node.marks)
if (fault !== undefined) return fault
pending.push(...(node.content ?? []))
}
return undefined
}
function marksFault(marks: readonly AdfMark[] | undefined): ConvertFault | undefined {
for (const mark of marks ?? []) {
const fault = attributesFault(mark.attrs, mark.type, markAttributeNesting)
if (fault !== undefined) return fault
}
return undefined
}
function attributesFault(attrs: AdfAttributes | undefined, type: string, levels: number = largestNesting): ConvertFault | undefined {
for (const [key, value] of Object.entries(attrs ?? {})) {
if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) }
}
return undefined
}
function isArrayOf<T>(value: unknown, guard: (item: unknown) => item is T): value is T[] { function isArrayOf<T>(value: unknown, guard: (item: unknown) => item is T): value is T[] {
return Array.isArray(value) && [...value].every(guard) return Array.isArray(value) && [...value].every(guard)
} }
@@ -89,6 +129,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) return typeof value === 'object' && value !== null && !Array.isArray(value)
} }
function notADocument(message: string): ConvertFault {
return { code: 'not-an-adf-document', message }
}
function describe(value: unknown): string { function describe(value: unknown): string {
if (typeof value === 'string') return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}` : value) if (typeof value === 'string') return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}` : value)
if (typeof value === 'function') return 'a function' if (typeof value === 'function') return 'a function'
+18 -8
View File
@@ -2,22 +2,32 @@ import { largestNesting } from './nesting.ts'
export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue } export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue }
export function isJsonValue(value: unknown, levels: number = largestNesting): value is JsonValue { export function isJsonValue(value: unknown): value is JsonValue {
const pending: { depth: number; item: unknown }[] = [{ depth: 0, item: value }] const pending: unknown[] = [value]
while (pending.length > 0) { while (pending.length > 0) {
const entry = pending.pop() const item = pending.pop()
if (entry === undefined) continue
const { depth, item } = entry
if (depth > levels) return false
if (item === null || typeof item === 'boolean' || typeof item === 'string') continue if (item === null || typeof item === 'boolean' || typeof item === 'string') continue
if (typeof item === 'number') { if (typeof item === 'number') {
if (!Number.isFinite(item)) return false if (!Number.isFinite(item)) return false
continue continue
} }
// A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them. // A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them.
if (Array.isArray(item)) for (const child of [...item]) pending.push({ depth: depth + 1, item: child }) if (Array.isArray(item)) for (const child of [...item]) pending.push(child)
else if (typeof item === 'object') for (const child of Object.values(item)) pending.push({ depth: depth + 1, item: child }) else if (typeof item === 'object') for (const child of Object.values(item)) pending.push(child)
else return false else return false
} }
return true return true
} }
export function overNested(value: JsonValue, levels: number = largestNesting): boolean {
const pending: { depth: number; item: JsonValue }[] = [{ depth: 0, item: value }]
while (pending.length > 0) {
const entry = pending.pop()
if (entry === undefined) continue
const { depth, item } = entry
if (depth > levels) return true
if (Array.isArray(item)) for (const child of item) pending.push({ depth: depth + 1, item: child })
else if (item !== null && typeof item === 'object') for (const child of Object.values(item)) pending.push({ depth: depth + 1, item: child })
}
return false
}
+9 -7
View File
@@ -3,11 +3,13 @@ import type { ConvertFault } from '../result.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { backslashEscape, claimsDirectiveLine } from './commonmark-grammar.ts' import { backslashEscape, claimsDirectiveLine } from './commonmark-grammar.ts'
import { backtickRun, closingBacktickRun } from './backtick-runs.ts' import { backtickRun, closingBacktickRun } from './backtick-runs.ts'
import { isJsonValue } from '../json-value.ts' import { isJsonValue, overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { runLength } from './emphasis-matching.ts' import { runLength } from './emphasis-matching.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
export type AttributeReading = { refusal: 'kind' | 'nesting'; value?: undefined } | { refusal?: undefined; value: VocabularyValue }
export type DirectiveValue = { decoded: string; spelling: string } export type DirectiveValue = { decoded: string; spelling: string }
export type DirectiveAttributes = ReadonlyMap<string, DirectiveValue> export type DirectiveAttributes = ReadonlyMap<string, DirectiveValue>
@@ -45,13 +47,13 @@ const orderFault = 'the {attrs} keys read in alphabetical order'
const pairFault = 'an attribute reads key=value, the value bare or double-quoted: this one does not' const pairFault = 'an attribute reads key=value, the value bare or double-quoted: this one does not'
const shapeFault = `a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; ${directiveLineEscape}` const shapeFault = `a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; ${directiveLineEscape}`
export function attributeValue(text: string, kind: AttributeKind): VocabularyValue | undefined { export function attributeValue(text: string, kind: AttributeKind): AttributeReading {
if (kind === 'string') return { kind, value: text } if (kind === 'string') return { value: { kind, value: text } }
if (kind === 'boolean') return text === 'true' || text === 'false' ? { kind, value: text === 'true' } : undefined if (kind === 'boolean') return text === 'true' || text === 'false' ? { value: { kind, value: text === 'true' } } : { refusal: 'kind' }
const parsed = parseJson(text) const parsed = parseJson(text)
if (parsed === undefined) return undefined if (parsed === undefined) return { refusal: 'kind' }
if (kind === 'json') return { kind, value: parsed } if (kind === 'number') return typeof parsed === 'number' ? { value: { kind, value: parsed } } : { refusal: 'kind' }
return typeof parsed === 'number' ? { kind, value: parsed } : undefined return overNested(parsed) ? { refusal: 'nesting' } : { value: { kind, value: parsed } }
} }
export function isBareToken(text: string): boolean { export function isBareToken(text: string): boolean {
+29 -5
View File
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { JsonValue } from '../../json-value.ts'
import type { Result } from '../../result.ts' import type { Result } from '../../result.ts'
import { adfToMarkdown } from '../../index.ts' import { adfToMarkdown, markdownToAdf } from '../../index.ts'
import { largestNesting } from '../../nesting.ts'
function document(...content: AdfNode[]): AdfDocument { function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 } return { content, type: 'doc', version: 1 }
@@ -334,10 +336,32 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth') assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
let attrs: AdfMark['attrs'] = { depth: 'x' } let attrs: AdfMark['attrs'] = { depth: 'x' }
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs } for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
assert.equal( const deeper = (key: string, type: string, levels: number = largestNesting): string =>
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
"not-an-adf-document: an ADF document's content holds ADF nodes: one of them is not", const nested = (levels: number): JsonValue => {
) let value: JsonValue = 1
for (let level = 0; level < levels; level += 1) value = [value]
return value
}
const card = (levels: number): AdfNode => ({ attrs: { data: nested(levels), url: 'https://example.com/a' }, type: 'inlineCard' })
const marked = (levels: number): AdfNode => ({
attrs: { panelType: 'info' },
content: [paragraph({ text: 'x', type: 'text' })],
marks: [{ attrs: { deep: nested(levels) }, type: 'em' }],
type: 'panel',
})
const roundTrips = (node: AdfNode): void => {
const spelled = adfToMarkdown(document(node))
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(node) })
}
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3))
assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard'))
assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), [])
roundTrips(paragraph(card(largestNesting)))
assert.equal(markdown(adfToMarkdown(document(marked(largestNesting - 2)))), deeper('deep', 'em', largestNesting - 3))
roundTrips(marked(largestNesting - 3))
}) })
test('escapes a literal delimiter that would merge with an emitted one', () => { test('escapes a literal delimiter that would merge with an emitted one', () => {
+2 -2
View File
@@ -4,7 +4,7 @@ import { adfDocumentFault, carriesOnly } from '../../adf/document.ts'
import { blockDirective } from '../../adf/block-directives.ts' import { blockDirective } from '../../adf/block-directives.ts'
import { carriedBlock } from '../opaque-carry.ts' import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts' import { emitInlineLine } from './inline-line.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { fencedCodeBlock } from '../backtick-runs.ts' import { fencedCodeBlock } from '../backtick-runs.ts'
import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts' import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts'
import { languageSlot } from '../code-language.ts' import { languageSlot } from '../code-language.ts'
@@ -24,7 +24,7 @@ const largestListMarker = 999999999
export function adfToMarkdown(document: AdfDocument): Result<string> { export function adfToMarkdown(document: AdfDocument): Result<string> {
const fault = adfDocumentFault(document) const fault = adfDocumentFault(document)
if (fault !== undefined) return failure('not-an-adf-document', fault, []) if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const blocks = emitBlocks(document.content ?? [], 'document', [], 0) const blocks = emitBlocks(document.content ?? [], 'document', [], 0)
if (!blocks.ok) return blocks if (!blocks.ok) return blocks
+4 -5
View File
@@ -3,7 +3,7 @@ import type { DirectiveSpan, Read } from './directive-syntax.ts'
import type { JsonSpelling } from '../canonical-json.ts' import type { JsonSpelling } from '../canonical-json.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { isAdfNode } from '../adf/document.ts' import { isAdfNode } from '../adf/document.ts'
import { isJsonValue } from '../json-value.ts' import { isJsonValue, overNested } from '../json-value.ts'
import { fencedCodeBlock } from './backtick-runs.ts' import { fencedCodeBlock } from './backtick-runs.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { malformedDirective, readSoleStringAttribute, spellAttributes, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
@@ -37,7 +37,7 @@ export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefine
} }
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<string> { function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<string> {
if (!isJsonValue(node, levels)) { if (!isJsonValue(node) || overNested(node, levels)) {
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path) return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
} }
return success(serializeCanonicalJson(node, spelling)) return success(serializeCanonicalJson(node, spelling))
@@ -47,9 +47,8 @@ function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): R
const parsed = parseJsonText(raw) const parsed = parseJsonText(raw)
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') } if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') }
const { value } = parsed const { value } = parsed
if (!isJsonValue(value, levels)) { if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
// Unbounded, the same walk parts the two causes one `false` holds (AGENTS.md §8). if (overNested(value, levels)) {
if (!isJsonValue(value, Number.POSITIVE_INFINITY)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } } return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } }
} }
if (serializeCanonicalJson(value, spelling) !== raw) { if (serializeCanonicalJson(value, spelling) !== raw) {
+5 -3
View File
@@ -1,6 +1,7 @@
import type { AdfAttributes } from '../../adf/document.ts' import type { AdfAttributes } from '../../adf/document.ts'
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts' import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
import type { DirectiveAttributes } from '../directive-syntax.ts' import type { DirectiveAttributes } from '../directive-syntax.ts'
import { attributeNestingMessage } from '../../adf/document.ts'
import { attributeValue, spellAttributeValue } from '../directive-syntax.ts' import { attributeValue, spellAttributeValue } from '../directive-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
@@ -22,10 +23,11 @@ export function readVocabulary(
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute: this one spells it`, path) if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute: this one spells it`, path)
const read = attributeValue(spelled.decoded, kind) const read = attributeValue(spelled.decoded, kind)
if (read === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path) if (read.refusal === 'nesting') return failure('unsupported-nesting-depth', attributeNestingMessage(key, type), path)
const spelling = spellAttributeValue(read) if (read.value === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path)
const spelling = spellAttributeValue(read.value)
if (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path) if (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path)
attrs[key] = read.value attrs[key] = read.value.value
} }
return success(attrs) return success(attrs)
} }
+3 -1
View File
@@ -3,6 +3,7 @@ import type { BlockDirective } from '../../adf/block-directives.ts'
import type { ConvertFault } from '../../result.ts' import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts' import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.ts' import type { Elsewhere } from './directive-attributes.ts'
import { attributeNestingMessage } from '../../adf/document.ts'
import { attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' import { attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument } from '../block-directive-arguments.ts' import { blockArgument } from '../block-directive-arguments.ts'
import { blockDirective } from '../../adf/block-directives.ts' import { blockDirective } from '../../adf/block-directives.ts'
@@ -94,7 +95,8 @@ function slotText(content: readonly AdfNode[]): string | undefined {
function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result<AdfMark[]> { function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result<AdfMark[]> {
const read = attributeValue(spelled.decoded, 'json') const read = attributeValue(spelled.decoded, 'json')
const marks = read === undefined || spellAttributeValue(read) !== spelled.spelling ? undefined : readMarkValues(read.value) if (read.refusal === 'nesting') return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, type), path)
const marks = read.value === undefined || spellAttributeValue(read.value) !== spelled.spelling ? undefined : readMarkValues(read.value.value)
if (marks === undefined) { if (marks === undefined) {
return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON: this one is not`, path) return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON: this one is not`, path)
} }
+10 -2
View File
@@ -431,12 +431,20 @@ test('names the attribute a node holds no reading for', () => {
assert.equal(content(markdownToAdf(':::table {isNumberColumnEnabled=yes}\n:::\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean') assert.equal(content(markdownToAdf(':::table {isNumberColumnEnabled=yes}\n:::\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean')
assert.equal(content(markdownToAdf('::media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number') assert.equal(content(markdownToAdf('::media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number')
assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340,"}\n:::\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json') assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340,"}\n:::\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json')
const deep = `${'['.repeat(largestNesting + 2)}${']'.repeat(largestNesting + 2)}`
assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${deep}"}\n:::\n`)), 'unsupported-node-shape: the colwidth attribute of tableCell is no json')
assert.equal(content(markdownToAdf(':::panel info {panelType=note}\nx\n:::\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument, never in {attrs}') assert.equal(content(markdownToAdf(':::panel info {panelType=note}\nx\n:::\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument, never in {attrs}')
assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}') assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}')
}) })
test('names the depth an attribute value nests past, never the kind the JSON reads as', () => {
const nested = (levels: number): string => `${'['.repeat(levels)}1${']'.repeat(levels)}`
const deeper = (key: string, type: string): string =>
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${nested(largestNesting + 1)}"}\n:::\n`)), deeper('colwidth', 'tableCell'))
assert.equal(content(markdownToAdf(`::rule {marks="${nested(largestNesting + 1)}"}\n`)), deeper('marks', 'rule'))
assert.equal(content(markdownToAdf(`::rule {marks="[{\\"attrs\\":{\\"deep\\":${nested(largestNesting - 2)}},\\"type\\":\\"em\\"}]"}\n`)), deeper('marks', 'rule'))
assert.equal(content(markdownToAdf(`::media {width="${nested(largestNesting + 1)}"}\n`)), 'unsupported-node-shape: the width attribute of media is no number')
})
test('names the attribute value spelled outside the canonical form', () => { test('names the attribute value spelled outside the canonical form', () => {
assert.equal(content(markdownToAdf('::rule {localId="a-1"}\n')), 'unsupported-node-shape: rule spells its localId attribute as localId=a-1') assert.equal(content(markdownToAdf('::rule {localId="a-1"}\n')), 'unsupported-node-shape: rule spells its localId attribute as localId=a-1')
assert.equal(content(markdownToAdf('::media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20') assert.equal(content(markdownToAdf('::media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20')
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const sourceRoot = dirname(fileURLToPath(import.meta.url))
const union = /export type ConvertErrorCode =\n((?:\s+\| '[a-z-]+'\n)+)/
const declared = /'([a-z-]+)'/g
const callSite = /(?:failure\(|code: )'([a-z-]+)'/g
function declaredCodes(): string[] {
const source = readFileSync(join(sourceRoot, 'result.ts'), 'utf8')
const members = union.exec(source)?.[1]
assert.notEqual(members, undefined, 'result.ts declares no ConvertErrorCode union')
return [...(members ?? '').matchAll(declared)].map(([, name]) => name ?? '').sort()
}
function calledCodes(): string[] {
const called = new Set<string>()
for (const name of readdirSync(sourceRoot, { encoding: 'utf8', recursive: true })) {
if (!name.endsWith('.ts') || name.endsWith('.test.ts') || name === 'result.ts') continue
for (const [, code] of readFileSync(join(sourceRoot, name), 'utf8').matchAll(callSite)) called.add(code ?? '')
}
return [...called].sort()
}
// The list is frozen at 0.1.0 (AGENTS.md §8), so a code outliving its cause is a removal that costs a MAJOR.
test('every ConvertErrorCode is the code of a production call site, and every call site names a declared one', () => {
assert.deepEqual(calledCodes(), declaredCodes())
})
+70
View File
@@ -482,3 +482,73 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
takes (§11), so six codes reach a `markdownToAdf` caller as well as an `adfToMarkdown` one. takes (§11), so six codes reach a `markdownToAdf` caller as well as an `adfToMarkdown` one.
The trailing pipe of a pipe-table row is optional in input, not required; the leading one The trailing pipe of a pipe-table row is optional in input, not required; the leading one
is what every row must carry. is what every row must carry.
- [x] **5c — The build and the release pipeline.** Split out of 5, which kept only the
maintainer's own acts. The build: `tsconfig.build.json` gains emit of JS and `.d.ts` to
`dist/` (its own `allowImportingTsExtensions` forces `noEmit`, so
`rewriteRelativeImportExtensions` lands beside it), plus `exports`/`files` in
`package.json`. Publish-on-version-change (§9) as `publish.sh`, run by a `main`-only job
needing the gate. The `ConvertErrorCode` freeze (§8) is checkable here: 3h landed the last
decision `corpus/unspellable/` held and the directory went with it, so what the code list
holds from here is permanent. The parser's own code additions are read here as one list
before that freeze — nine sessions mint them independently, and one cause wearing two codes
is breaking to undo after `0.1.0`. That read gets a test rather than an eye — every
`ConvertErrorCode` member named at a production call site, the way `spec.test.ts` guards the
node tables — since `unspelled-block-separation` outlived its cause until 3h went looking.
All thirteen have a call site; the audit's find was the depth one 5 predicted, read wrong in
its own text: an attribute value past 500 levels was `unsupported-node-shape` on parse and
`not-an-adf-document` on emit, the document guard counting the `attrs` object as a level the
parser does not, so a value at exactly 500 parsed into a document the emitter then refused.
Depth left the shape predicates on both sides: `isJsonValue` structural and `overNested`
beside it, `adfDocumentFault` returning the code with the message and `attributeValue` the
reason it refused, so both directions answer with `unsupported-nesting-depth` naming the
attribute, and `isAdfDocument` calls a deep document a document as it always did a deep
block.
`engines.node` gets its one-line proof too — the built entrypoint imported and round-tripped
under a pinned Node 18 image, which cannot run the suite that type stripping wants 22+ for,
but proves exactly what the field claims. Beside it, the emitted `.d.ts` typechecked from a
consumer's position: declaration emit leaves the `.ts` specifiers `rewriteRelativeImportExtensions`
rewrites in the JavaScript, and nothing else in the repo reads them the way an installed
consumer would. 3k's exception list landing after the release left the README's
canonical-fixpoint sentence claiming more than `0.1.0` keeps — 3e names three shapes that
parse and then refuse — so it now says a parse succeeding is no promise of a way back, and
names them.
- [x] **5d — The browser leg.** §6's browser half is checkable on the emitted `dist/index.js` a
browser can load — the compile gate names no host API, and a real page converting the corpus
is the other half. Headless Firefox is that page, settling both at once: the browser proof,
and the only SpiderMonkey there is, the gate's three engine legs being two V8s and a
JavaScriptCore that is not Safari's. The mechanism is the decision this item opens with: a
browser leg wants an image, a driver and a way to carry a verdict back out, none of which
the gate's plain `docker run` per engine has. The answer is `with_firefox`, which runs the
Firefox image beside the node one in a shared network namespace, so the page's server and
the driver are each other's `127.0.0.1` and no user-defined network, container name or
geckodriver `--allow-hosts` entry is wanted; its `EXIT INT TERM` trap bakes in the container
id, since the `local` holding it is gone by the time the trap fires. `browser-tests/run.js`
serves the repo, drives one `execute/sync` and asserts the results against the corpus with the
Node-side `assert.deepEqual` the corpus runner uses, so the browser page holds no second copy
of the comparison. The whole corpus fits: 118 fixtures in 8s warm against a 120s script
timeout — no slice was worth choosing. A `try` around the dynamic import is what turns a
broken build into SpiderMonkey's own message rather than an undefined global.
**Settled** (the maintainer, 2026-09-04): `selenium/standalone-firefox` over the smaller
`instrumentisto/geckodriver`, currency over size — the leg's whole worth is a real
SpiderMonkey, which decays the moment the pin stops moving, and the smaller image was four
Firefox majors behind with a publisher that may go quiet while Renovate stays silent.
## 5 — Ship `0.1.0`
- [ ] **5 — Ship `0.1.0`.** Only the maintainer's own acts are left (§15): make the Gitea repo
public (§6), create the `NPM_TOKEN` secret, confirm the Actions token may push tags — the
publish succeeds and the tag push then reddens the run, though the next push to `main`
retries the tag alone — and open the bump PR that sets `version` to `0.1.0` and drops
`private: true`, the guard against any earlier publish. The bump and the drop go in one
commit: dropping `private` alone publishes `0.0.0`, which also differs from npm's nothing. `0.1.0` is the
markdown round-trip: both markdown directions, the types, `isAdfDocument`, proved over the
checked-in corpus.
**Settled** (the maintainer, 2026-09-01): the round-trip proved over the checked-in corpus
is what `0.1.0` ships on, and the open-ended proof work follows it rather than gating it —
3k's spec suite and 4's generators and maintainer-supplied payloads are `0.2.0`, 4b's retry
`0.1.1`. A consumer using the library is worth more than a wider proof nobody has needed
yet, and §8's pre-1.0 rules cover what the wider proof then finds.
**Shipped** 2026-09-05: `@larvit/adf-codec@0.1.0` published and `v0.1.0` tagged on `8a847de`. Publishing needed a
bypass-2FA token — the account carrying no write-2FA requirement was not enough, npm demanded an
OTP until the token itself bypassed it.
+87 -38
View File
@@ -5,8 +5,8 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h
## Milestones ## Milestones
Shipping order: 3h, 3i, 3j, 5a, 5b, 5 → `0.1.0`; 4b and 4c`0.1.1`; 4, 3k`0.2.0`; 6, 7 → Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 5e before 2027-01; 3k, 4, 4b, 4c, 5g, 10, 11, 12`0.2.0`; 4d, 5f`0.2.1`;
`0.3.0`. 6, 7 → `0.3.0`; 9 → TBD.
The numbering is the order the work was planned in, not the order it ships. The numbering is the order the work was planned in, not the order it ships.
- [x] **0 — Scaffold.** - [x] **0 — Scaffold.**
@@ -84,7 +84,7 @@ The numbering is the order the work was planned in, not the order it ships.
a document that round-trips proves no other document shares its spelling — so decide here a document that round-trips proves no other document shares its spelling — so decide here
whether that gate stays as the parser-free, faster-failing signal or goes; the half holding whether that gate stays as the parser-free, faster-failing signal or goes; the half holding
no fixture duplicates is hygiene rather than a round-trip claim, and stays either way. no fixture duplicates is hygiene rather than a round-trip claim, and stays either way.
- [ ] **4b — The block walk's retry (`0.1.1`).** `emitBlock` walks a subtree twice wherever - [ ] **4b — The block walk's retry (`0.2.0`).** `emitBlock` walks a subtree twice wherever
`readableBlock` reads it whole and then gives up — a list item whose first line reads back `readableBlock` reads it whole and then gives up — a list item whose first line reads back
as a thematic break — and the walk below does the same, so the cost doubles per level: as a thematic break — and the walk below does the same, so the cost doubles per level:
3.4kB of nested lists takes half a second, depth 20 about eight, depth 24 minutes. It 3.4kB of nested lists takes half a second, depth 20 about eight, depth 24 minutes. It
@@ -95,8 +95,12 @@ The numbering is the order the work was planned in, not the order it ships.
fallback. Memoizing `emitBlock` is the shortcut, and the node reference is the wrong key: a fallback. Memoizing `emitBlock` is the shortcut, and the node reference is the wrong key: a
caller may hold one node object at two positions, where the cached depth and path are caller may hold one node object at two positions, where the cached depth and path are
another node's. `0.1.0` ships with the retry in it, so a deep document is slow rather than another node's. `0.1.0` ships with the retry in it, so a deep document is slow rather than
wrong until the patch. wrong until the patch. `adfDocumentFault` is the second site to look at: `isNodeArray` reads
- [ ] **4c — The scanning rule's remaining sites (`0.1.1`).** A trailing-anchored regex re-walks every node and attribute value, then `nestingFault` reads them again, so the emit entry the
export persona runs in bulk walks the document twice. Both walks are linear, so this is a
constant factor rather than 4b's class change, and the parting is what gives depth its own
code (§8) — measure before joining them back.
- [ ] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks
its run from every start position, so an interior whitespace run costs quadratic time rather its run from every start position, so an interior whitespace run costs quadratic time rather
than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk
replaced the regex. Three sites the same sweep did not reach: `normalizeLabel` in replaced the regex. Three sites the same sweep did not reach: `normalizeLabel` in
@@ -108,50 +112,95 @@ The numbering is the order the work was planned in, not the order it ships.
cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level
guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents
nobody typed. nobody typed.
- [ ] **5Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret, - [ ] **4dWhat the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces
the repo made public first (§6). The `ConvertErrorCode` freeze (§8) is checkable here: 3h none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
landed the last decision `corpus/unspellable/` held and the directory went with it, so what a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its
the code list holds from here is permanent. The parser's own code own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and
additions are read here as one list before that freeze — nine sessions mint them this one prints nothing, so name the leg and the image before each. The longest leg is the
independently, and one cause wearing two codes is breaking to undo after `0.1.0` — one is quietest: `test_output=$(… npm test 2>&1)` buffers the whole Node run to replay it after,
known already: a json attribute value past 500 levels reads `unsupported-node-shape` on because the zero-test guard greps the count — stream it and grep a copy (`tee`), rather than
parse but `unsupported-nesting-depth` through the carry on emit. That read trading the output for the guard. And two legs are silenced outright, `npm pack` and the
gets a test rather than an eye — every `ConvertErrorCode` member named at a production call tarball install, whose `>/dev/null` predates the offline install that made them quick and
site, the way `spec.test.ts` guards the node tables — since `unspelled-block-separation` quiet. `publish.sh` owes the same: today it says nothing between reading `private` and the
outlived its cause until 3h went looking. `0.1.0` registry answering, which is where its `npm ci` and rebuild sit — the seconds §9 accepts
is the markdown round-trip: both markdown directions, the types, `isAdfDocument`. The build rather than promoting the gate's `dist`, and unmeasured until the log shows them. Per-leg
lands here: `tsconfig.build.json` gains emit of JS and `.d.ts` to `dist/` (its own timing is what turns "slow or hung" from a guess into a reading; the browser leg's own
`allowImportingTsExtensions` forces `noEmit`, so `rewriteRelativeImportExtensions` lands 5.47.9s against a 17s warm gate is the number that made it obviously cheap.
beside it), plus `exports`/`files` in `package.json`. The - [x] **5 — Ship `0.1.0`.**
maintainer's bump PR also removes `private: true`, the guard against any earlier publish. - [ ] **5e — The publish token's deadline (before 2027-01).** `0.1.0` published only once the npm
§6's browser half is first checkable here, on the emitted `dist/index.js` a browser can token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm
load — the compile gate names no host API, and a real page converting the corpus is the answered `EOTP` until the token itself bypassed. npm retires bypass-2FA tokens for direct
other half. Headless Firefox is that page, settling both at once: the browser proof, and the publishing around January 2027, and its replacement — trusted publishing over OIDC —
only SpiderMonkey there is, `ci.sh`'s three legs being two V8s and a JavaScriptCore that is supports GitHub Actions, GitLab CI, CircleCI and Buildkite, not Gitea or self-hosted
not Safari's. `engines.node` gets its one-line proof here runners. So the release path has an expiry date and no drop-in successor yet. Revisit before
too — `import('./dist/index.js')` under a pinned Node 18 image, which cannot run the the deadline: whether npm has added Gitea or self-hosted OIDC, and otherwise whether the
suite that type stripping wants 22+ for, but proves exactly what the field claims. release moves to a human-approved staged publish — which fits badly with publish-on-merge,
**Settled** (the maintainer, 2026-09-01): the round-trip proved over the checked-in corpus and is the trade to weigh rather than discover on a red release run.
is what `0.1.0` ships on, and the open-ended proof work follows it rather than gating it — - [ ] **5f — Publish the bundle size (`0.2.1`).** Measure the shipped artifact and put the number in the
3k's spec suite and 4's generators and maintainer-supplied payloads are `0.2.0`, 4b's retry README, kept honest by the release pipeline rather than by a human re-reading it. The
`0.1.1`. A consumer using the library is worth more than a wider proof nobody has needed quantity is what a consumer downloads and loads: the tarball `npm pack` produces, its
yet, and §8's pre-1.0 rules cover what the wider proof then finds. 3k's exception list unpacked `dist`, and the built JavaScript minified + gzipped — the figure the competitors
landing after the release leaves the README's canonical-fixpoint sentence claiming more than advertise (marklassian's "12kb") and the only apple-to-apple one, since ours ships tsc's
`0.1.0` keeps — 3e names three shapes that parse and then refuse — so the release narrows unminified output and no minifier yet (decide here whether to minify for the build or report
that sentence or lists them. `[x](http://a\b)` is one to narrow it against: it parses the unminified gzip). A publish/pipeline leg measures it and fails when the README figure
cleanly and refuses on the way back, so a successful parse does not imply a spellable drifts, so the number can't rot; the figure lands in README §The package beside the
document. "no runtime dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked
221.5 kB, JS gzipped 45.6 kB.
- [ ] **5g — Reweight the README for the reader (`0.2.0`).** It opens with the pre-launch rationale —
Atlassian's REST APIs, `pf-editor-service/convert` being decommissioned, a link to
JRACLOUD-77436 — where a shipped package should answer what it is, what it does and for whom
first, then the shortest runnable example; the reader's top seconds go to "why this exists"
instead of "what I can do with it". Demote the Jira/endpoint background to a later "why
losslessness" note or drop it — the internal references (the `jira.atlassian.com` URL,
`pf-editor-service/convert`) don't belong in published text at all, no ticket IDs or internal
URLs. The `0.3.0` HTML future should read as an aside, not the lede: the package reads as a
shipped `0.1.0`, not a work-in-progress.
- [x] **5a — Rename to `@larvit/adf-codec`.** - [x] **5a — Rename to `@larvit/adf-codec`.**
- [x] **5b — The consumer's error surface.** - [x] **5b — The consumer's error surface.**
- [x] **5b1 — The error's source position.** - [x] **5b1 — The error's source position.**
- [x] **5b2 — The error messages.** - [x] **5b2 — The error messages.**
- [x] **5b3 — The code list and the flavour's gaps.** - [x] **5b3 — The code list and the flavour's gaps.**
- [x] **5b4 — The README's consumer surface.** - [x] **5b4 — The README's consumer surface.**
- [x] **5c — The build and the release pipeline.**
- [x] **5d — The browser leg.**
- [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity - [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts. scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` / - [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /
`htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from here (§10). `htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from here (§10).
- [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists. - [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists.
- [ ] **9 — The online sandbox.** A web page with two textboxes converting back and forth between ADF and markdown, powered by the library's browser build.
- [ ] **10 — Lossy conversion (`0.2.0`).** A direction that only converts what Markdown actually supports, keeping the ADF's data while dropping what markdown cannot hold — format, design and the richer nodes.
- [ ] **11 — Evaluate `@atlaskit/adf-schema` (`0.2.0`).** Whether to add `@atlaskit/adf-schema` as a dev dependency to use as truth for the ADF schema.
- [ ] **12 — The `!adf:` re-spelling (`0.2.0`).** Replace the colon directive grammar with the
namespaced prefix, a breaking change to the emitted contract (shipped `0.1.0`, so §8 makes it
`0.2.0`). Forms: block container `!adf:name arg {attrs}``!adf:/name` — the `/` parts open
from close, nestable without a fence-length discipline, so the `::::`/`:::::` runs and their
length rule go and every container opens the constant `!adf:`; block leaf `!adf:name arg
{attrs}` with no closer; inline node `!adf:name[content]{attrs}`; directive marks
`!adf:border`/`subsup`/`textColor`/`underline` `[content]{attrs}`. Attributes and their
escaping stay `{key=value}`; the literal escape is `\!adf:`; a line opening `!adf:` claims as
today's colon-run does. Leaf vs container is decided by the node's content model rather than
syntax — the `::`/`:::` split and §4's name-set-independent recognition go, a simplification
the carry makes safe (an unknown *block* node already rides the fence, not the directive).
The carry's reserved name becomes `carry`, both spellings — the block fence info string
`` `carry` `` and the inline `!adf:carry{json="…"}` — named for what it does: it carries a node
verbatim, never "unknown-node", since a known node no section spells where it stands rides it
too. A spelling change, not a semantic one: no `ConvertErrorCode` is added, removed or renamed,
the round-trip guarantee and the carry both hold through it. Mechanical surface: the grammar in
`spec/flavour.md`, `src/adf/block-directives.ts` + `inline-directives.ts`, `src/markdown/`'s
`directive-syntax.ts`, `opaque-carry.ts` and the `emit/` + `parse/` readers, every corpus
fixture (round-trip, normalization and `errors/`), `spec.test.ts`'s prose reader, and the
README's examples.
- [ ] **12a — The spec and the decision.** Rewrite `spec/flavour.md` to the `!adf:` grammar, and
record the departures in `AGENTS.md` §4 (leaf/container by content model, carry renamed
`carry`).
- [ ] **12b — The emit side.** `adfToMarkdown` spells `!adf:` / `!adf:/name` / `!adf:carry`; its
fixtures re-spelled, green.
- [ ] **12c — The parse side and the round-trip.** `markdownToAdf` reads it back; the round-trip
corpus, the `errors/` fixtures and the CommonMark spec suite re-spelled,
`markdownToAdf(adfToMarkdown(doc))` still equals `doc`.
- [ ] **12d — The README and the sweep.** The README's examples follow; sweep docs and fixtures
for any stale `::`/`:name` spelling.
## The ADF inventory to cover ## The ADF inventory to cover
+4 -1
View File
@@ -7,9 +7,12 @@
"types": [], "types": [],
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"declaration": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "outDir": "dist",
"rewriteRelativeImportExtensions": true,
"rootDir": "src",
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"strict": true, "strict": true,