Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f81bebe8d0 | |||
| d1331a6612 | |||
| 6e3b7e692c | |||
| dfbb92b7ab | |||
| 12b57a06a9 | |||
| e6610d7057 | |||
| 026ea5e1b6 | |||
| 5d19bdbae7 | |||
| 75f35eef0b | |||
| 8a847dea0a | |||
| bcab476c87 | |||
| 197dee199b | |||
| 611336bb33 | |||
| 0523bb8182 | |||
| 4d13583d6e | |||
| 9e688f4b00 | |||
| edf11304e3 | |||
| e9a70ac8f6 | |||
| b58f51f846 | |||
| 7612feb75e | |||
| 67df1e2f4b | |||
| 0dfcc0f9ca | |||
| d9056973a1 |
@@ -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
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ 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 some things the flavour has no escape for — a link
|
||||||
|
destination or title holding a backslash or newline, a paragraph opening with a code span whose
|
||||||
|
backticks read back as a fence — so a parse succeeding does not imply a spellable document;
|
||||||
|
`corpus/commonmark-spec/exceptions.json` 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
|
||||||
@@ -55,15 +59,19 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros
|
|||||||
why ~20 lines of own code cannot do the job, who maintains it, and what auditing it costs. So the
|
why ~20 lines of own code cannot do the job, who maintains it, and what auditing it costs. So the
|
||||||
CommonMark and HTML parsers are written in this repo. A table a standard fixes is data rather than
|
CommonMark and HTML parsers are written in this repo. A table a standard fixes is data rather than
|
||||||
a dependency: HTML5's 2125 semicolon-terminated character references ship packed in their own
|
a dependency: HTML5's 2125 semicolon-terminated character references ship packed in their own
|
||||||
module, so entity decoding is complete without one. `devDependencies`: few, each earning its keep;
|
module, so entity decoding is complete without one. The CommonMark spec suite is the same shape of
|
||||||
they never reach a consumer.
|
data and ships vendored at `corpus/commonmark-spec/` rather than as the `commonmark-spec` dev
|
||||||
|
dependency — that package is CommonJS-only, and Renovate auto-bumping a spec version would silently
|
||||||
|
point the vendored exception list's example numbers at a renumbered suite. A spec bump is a
|
||||||
|
deliberate re-pin, exceptions re-derived by hand beside it. `devDependencies`: few, each earning its
|
||||||
|
keep; they never reach a consumer.
|
||||||
|
|
||||||
## 6. The package contract
|
## 6. The package contract
|
||||||
|
|
||||||
- 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 +137,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 +166,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 +195,25 @@ 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 round-trip, normalization and error fixtures — the `commonmark-spec`
|
||||||
|
sort is the Node suite's to check — 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
|
||||||
|
|||||||
@@ -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&b=2>` and `[a](/x 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` §5–6.
|
Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
|
||||||
|
Contract: `AGENTS.md` §5–6.
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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)
|
||||||
|
.filter((name) => name !== 'commonmark-spec')
|
||||||
|
.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`,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
+9
-1
@@ -12,5 +12,13 @@ One directory per contract kind, each landing with its milestone:
|
|||||||
pins which error.
|
pins which error.
|
||||||
- `real-payloads/` — `<name>.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No
|
- `real-payloads/` — `<name>.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No
|
||||||
expected markdown.
|
expected markdown.
|
||||||
|
- `commonmark-spec/` — the CommonMark suite run against `markdownToAdf` by three checks. `spec.json`
|
||||||
|
is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json`
|
||||||
|
pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a
|
||||||
|
`reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node
|
||||||
|
model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap a later
|
||||||
|
milestone may close).
|
||||||
|
|
||||||
JSON is editor-normal (AGENTS.md §2), two-space indent, keys sorted.
|
JSON is editor-normal (AGENTS.md §2), two-space indent, keys sorted. `spec.json` is the vendored,
|
||||||
|
upstream machine-readable suite, byte-exact (CommonMark 0.31.2, CC-BY-SA-4.0, © John MacFarlane),
|
||||||
|
and is not re-serialized by the corpus gate.
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"check": "fixpoint",
|
||||||
|
"divergence": "unspellable-link",
|
||||||
|
"example": 196,
|
||||||
|
"kind": "unspellable",
|
||||||
|
"reason": "The link title holds literal newlines no escape spells."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "fixpoint",
|
||||||
|
"divergence": "unspellable-link",
|
||||||
|
"example": 202,
|
||||||
|
"kind": "unspellable",
|
||||||
|
"reason": "The link destination holds a backslash the flavour cannot escape."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "ul 2/1",
|
||||||
|
"example": 301,
|
||||||
|
"kind": "pending",
|
||||||
|
"reason": "A list continuing past a marker change renders as two lists, the parser opens one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "ol 2/1",
|
||||||
|
"example": 302,
|
||||||
|
"kind": "pending",
|
||||||
|
"reason": "A list continuing past a marker change renders as two lists, the parser opens one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "fixpoint",
|
||||||
|
"divergence": "unspellable-line-start",
|
||||||
|
"example": 330,
|
||||||
|
"kind": "unspellable",
|
||||||
|
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "fixpoint",
|
||||||
|
"divergence": "unspellable-line-start",
|
||||||
|
"example": 331,
|
||||||
|
"kind": "unspellable",
|
||||||
|
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "fixpoint",
|
||||||
|
"divergence": "unspellable-line-start",
|
||||||
|
"example": 340,
|
||||||
|
"kind": "unspellable",
|
||||||
|
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 2/1",
|
||||||
|
"example": 369,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 2/1",
|
||||||
|
"example": 373,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 2/1",
|
||||||
|
"example": 389,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/3",
|
||||||
|
"example": 393,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 1/5",
|
||||||
|
"example": 394,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 1/3",
|
||||||
|
"example": 395,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/3",
|
||||||
|
"example": 399,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/2",
|
||||||
|
"example": 404,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/3",
|
||||||
|
"example": 406,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 2/1",
|
||||||
|
"example": 407,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 2/1",
|
||||||
|
"example": 408,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 2/1",
|
||||||
|
"example": 409,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/3",
|
||||||
|
"example": 410,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/3",
|
||||||
|
"example": 411,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/2",
|
||||||
|
"example": 413,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/2",
|
||||||
|
"example": 414,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/2",
|
||||||
|
"example": 415,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 3/1",
|
||||||
|
"example": 417,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 2/5 strong 1/3",
|
||||||
|
"example": 418,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 1/2",
|
||||||
|
"example": 422,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 1/3",
|
||||||
|
"example": 424,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 2/1",
|
||||||
|
"example": 425,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 2/1",
|
||||||
|
"example": 426,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 2/1",
|
||||||
|
"example": 427,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 1/3",
|
||||||
|
"example": 428,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 1/3",
|
||||||
|
"example": 429,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 1/2",
|
||||||
|
"example": 430,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 1/2",
|
||||||
|
"example": 431,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/3 strong 2/5",
|
||||||
|
"example": 432,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 1/2",
|
||||||
|
"example": 433,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 2/1",
|
||||||
|
"example": 461,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 2/1",
|
||||||
|
"example": 463,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 2/1",
|
||||||
|
"example": 464,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 2/1",
|
||||||
|
"example": 465,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 3/1",
|
||||||
|
"example": 466,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "strong 2/1",
|
||||||
|
"example": 468,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "CommonMark nests same-kind elements; the single mark collapses them to one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/3",
|
||||||
|
"example": 470,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/2",
|
||||||
|
"example": 478,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/2",
|
||||||
|
"example": 479,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "a 1/0",
|
||||||
|
"example": 484,
|
||||||
|
"kind": "pending",
|
||||||
|
"reason": "An empty link text stays literal text; CommonMark renders an empty link."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "text",
|
||||||
|
"divergence": "\"\" against \"[](./target.md)\"",
|
||||||
|
"example": 484,
|
||||||
|
"kind": "pending",
|
||||||
|
"reason": "An empty link text stays literal text; CommonMark renders an empty link."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "a 1/0",
|
||||||
|
"example": 487,
|
||||||
|
"kind": "pending",
|
||||||
|
"reason": "An empty link text stays literal text; CommonMark renders an empty link."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "text",
|
||||||
|
"divergence": "\"\" against \"[]()\"",
|
||||||
|
"example": 487,
|
||||||
|
"kind": "pending",
|
||||||
|
"reason": "An empty link text stays literal text; CommonMark renders an empty link."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "fixpoint",
|
||||||
|
"divergence": "unspellable-link",
|
||||||
|
"example": 502,
|
||||||
|
"kind": "unspellable",
|
||||||
|
"reason": "The link destination holds a backslash the flavour cannot escape."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "a 1/5 em 1/4",
|
||||||
|
"example": 516,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/3",
|
||||||
|
"example": 519,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "a 1/5 em 1/4",
|
||||||
|
"example": 530,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/2",
|
||||||
|
"example": 533,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "a 1/0",
|
||||||
|
"example": 540,
|
||||||
|
"kind": "pending",
|
||||||
|
"reason": "The case-folding shortcut reference is unresolved; CommonMark folds case and links."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "text",
|
||||||
|
"divergence": "\"ẞ\" against \"[ẞ]\"",
|
||||||
|
"example": 540,
|
||||||
|
"kind": "pending",
|
||||||
|
"reason": "A case-folding shortcut reference is unresolved; CommonMark folds case and links."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "a 1/2",
|
||||||
|
"example": 554,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "a 1/2",
|
||||||
|
"example": 558,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "a 1/2",
|
||||||
|
"example": 559,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/2",
|
||||||
|
"example": 638,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"check": "count",
|
||||||
|
"divergence": "em 1/2",
|
||||||
|
"example": 639,
|
||||||
|
"kind": "mark-model",
|
||||||
|
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 31
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 148
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 149
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 151
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 152
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 153
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 154
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 155
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 157
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 158
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 159
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 160
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 161
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 162
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 163
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 164
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 165
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 166
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 167
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 168
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 169
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 170
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 171
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 172
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 173
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 174
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 175
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 176
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 177
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 178
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 179
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 181
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 182
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 183
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 184
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 185
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 186
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 187
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 188
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 189
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 190
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 191
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 201
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 308
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 309
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 344
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 475
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 476
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 477
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 491
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 494
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 517
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 524
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 531
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 536
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 572
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 573
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 576
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 577
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 579
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 584
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 585
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 586
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 587
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 588
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 589
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-image",
|
||||||
|
"example": 591
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 613
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 614
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 615
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 616
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 617
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 623
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 625
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 626
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 627
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 628
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 629
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 630
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 631
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 642
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "unmappable-html",
|
||||||
|
"example": 643
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
unsupported-nesting-depth
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
:::tableCell {colwidth="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"}
|
||||||
|
:::
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Generated
+3
-3
@@ -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": {
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 })
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "adf-codec-package-tests",
|
||||||
|
"private": true,
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["consumer.ts"]
|
||||||
|
}
|
||||||
+12
-2
@@ -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
@@ -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
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import type { AdfDocument, AdfNode } from './adf/document.ts'
|
||||||
|
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||||
|
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus', 'commonmark-spec')
|
||||||
|
const checks = ['count', 'fixpoint', 'text'] as const
|
||||||
|
|
||||||
|
type Check = (typeof checks)[number]
|
||||||
|
type ExceptionKind = 'mark-model' | 'pending' | 'unspellable'
|
||||||
|
|
||||||
|
type SpecExample = { example: number; html: string; markdown: string; section: string }
|
||||||
|
|
||||||
|
type Exception = { check: Check; divergence: string; example: number; kind: ExceptionKind; reason: string }
|
||||||
|
|
||||||
|
type Refusal = { code: string; example: number }
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCheck(value: unknown): value is Check {
|
||||||
|
return checks.some((check) => check === value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKind(value: unknown): value is ExceptionKind {
|
||||||
|
return value === 'mark-model' || value === 'pending' || value === 'unspellable'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSpecExample(value: unknown): value is SpecExample {
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
return typeof value['example'] === 'number' && typeof value['html'] === 'string' && typeof value['markdown'] === 'string' && typeof value['section'] === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isException(value: unknown): value is Exception {
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
return (
|
||||||
|
isCheck(value['check']) &&
|
||||||
|
typeof value['divergence'] === 'string' &&
|
||||||
|
value['divergence'].length > 0 &&
|
||||||
|
typeof value['example'] === 'number' &&
|
||||||
|
isKind(value['kind']) &&
|
||||||
|
typeof value['reason'] === 'string' &&
|
||||||
|
value['reason'].length > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRefusal(value: unknown): value is Refusal {
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
return typeof value['code'] === 'string' && value['code'].length > 0 && typeof value['example'] === 'number'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson<T>(name: string, guard: (value: unknown) => value is T, shape: string): T[] {
|
||||||
|
const parsed: unknown = JSON.parse(readFileSync(join(root, name), 'utf8'))
|
||||||
|
assert.ok(Array.isArray(parsed), `${name} is not an array`)
|
||||||
|
return parsed.map((value, index) => {
|
||||||
|
assert.ok(guard(value), `${name} holds a ${shape} with the wrong shape at ${index}`)
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = readJson('spec.json', isSpecExample, 'spec example')
|
||||||
|
const exceptions = readJson('exceptions.json', isException, 'exception')
|
||||||
|
const refusals = readJson('refusals.json', isRefusal, 'refusal')
|
||||||
|
|
||||||
|
const exampleToRefusal = new Map(refusals.map((refusal) => [refusal.example, refusal.code]))
|
||||||
|
const exceptionIndex = new Map(exceptions.map((entry) => [`${entry.example}:${entry.check}`, entry]))
|
||||||
|
|
||||||
|
test('the CommonMark spec suite is 0.31.2, vendored byte-exact', () => {
|
||||||
|
const digest = createHash('sha256').update(readFileSync(join(root, 'spec.json'))).digest('hex')
|
||||||
|
assert.equal(digest, 'd431b29d97b6f73e69d547109cf5081578fac931e72afe95639ebe766c1b2a20')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every exception is unique, names a parsing example, and files a fixpoint only as unspellable', () => {
|
||||||
|
assert.equal(exceptionIndex.size, exceptions.length, 'one exception repeats an example and check another holds')
|
||||||
|
for (const entry of exceptions) {
|
||||||
|
assert.ok(spec.some((candidate) => candidate.example === entry.example), `exception ${entry.example} names no example in the suite`)
|
||||||
|
assert.equal(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`)
|
||||||
|
if (entry.check === 'fixpoint') assert.equal(entry.kind, 'unspellable', `exception ${entry.example} files a fixpoint divergence as ${entry.kind}; a fixable hole is given the spelling instead`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the refusal list is unique per example and names real examples', () => {
|
||||||
|
assert.equal(exampleToRefusal.size, refusals.length, 'one refusal repeats an example another holds')
|
||||||
|
for (const example of exampleToRefusal.keys()) assert.ok(spec.some((entry) => entry.example === example), `refusal ${example} names no example in the suite`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A mark is counted once per text node it touches (AGENTS.md §14).
|
||||||
|
const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul']
|
||||||
|
const nodeElement: Record<string, string> = {
|
||||||
|
blockquote: 'blockquote',
|
||||||
|
bulletList: 'ul',
|
||||||
|
codeBlock: 'pre',
|
||||||
|
hardBreak: 'br',
|
||||||
|
listItem: 'li',
|
||||||
|
media: 'img',
|
||||||
|
mediaInline: 'img',
|
||||||
|
orderedList: 'ol',
|
||||||
|
rule: 'hr',
|
||||||
|
}
|
||||||
|
const markElement: Record<string, string> = { code: 'code', em: 'em', link: 'a', strong: 'strong' }
|
||||||
|
const blockTags = new Set(['blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'li', 'ol', 'p', 'pre', 'ul'])
|
||||||
|
|
||||||
|
function tagName(tag: string): string {
|
||||||
|
return tag.slice(1).replace(/^\//, '').split(/[\s/>]/)[0] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCounts(): Record<string, number> {
|
||||||
|
return Object.fromEntries(countKeys.map((key) => [key, 0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenceCounts(html: string): Record<string, number> {
|
||||||
|
const counts = emptyCounts()
|
||||||
|
let inPre = false
|
||||||
|
for (let index = 0; index < html.length; index += 1) {
|
||||||
|
if (html[index] !== '<') continue
|
||||||
|
const close = html.indexOf('>', index)
|
||||||
|
if (close === -1) break
|
||||||
|
const tag = html.slice(index, close + 1)
|
||||||
|
if (tag.startsWith('</')) {
|
||||||
|
if (tagName(tag) === 'pre') inPre = false
|
||||||
|
index = close
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const name = tagName(tag)
|
||||||
|
if (name === 'pre') {
|
||||||
|
inPre = true
|
||||||
|
counts['pre'] = (counts['pre'] ?? 0) + 1
|
||||||
|
} else if (name === 'code' && inPre) {
|
||||||
|
// A code block's `<code>` is the `<pre>`'s body, already counted.
|
||||||
|
} else if (countKeys.includes(name)) {
|
||||||
|
counts[name] = (counts[name] ?? 0) + 1
|
||||||
|
}
|
||||||
|
index = close
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeCounts(document: AdfNode): Record<string, number> {
|
||||||
|
const counts = emptyCounts()
|
||||||
|
const pending: AdfNode[] = [document]
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const node = pending.pop()
|
||||||
|
if (node === undefined) continue
|
||||||
|
if (node.text !== undefined) {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const mark of node.marks ?? []) {
|
||||||
|
const element = markElement[mark.type]
|
||||||
|
if (element !== undefined) seen.add(element)
|
||||||
|
}
|
||||||
|
for (const element of seen) counts[element] = (counts[element] ?? 0) + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (node.type === 'heading') {
|
||||||
|
const level = node.attrs?.['level']
|
||||||
|
if (typeof level === 'number') counts[`h${level}`] = (counts[`h${level}`] ?? 0) + 1
|
||||||
|
pending.push(...(node.content ?? []))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const element = nodeElement[node.type]
|
||||||
|
if (element !== undefined) counts[element] = (counts[element] ?? 0) + 1
|
||||||
|
pending.push(...(node.content ?? []))
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
const namedEntity: Record<string, string> = { amp: '&', gt: '>', lt: '<', ouml: 'ö', quot: '"' }
|
||||||
|
|
||||||
|
function decodeHtmlEntity(text: string, index: number): { length: number; text: string } | undefined {
|
||||||
|
if (text[index] !== '&') return undefined
|
||||||
|
const end = text.indexOf(';', index)
|
||||||
|
if (end === -1 || end - index > 8) return undefined
|
||||||
|
const reference = text.slice(index, end + 1)
|
||||||
|
const named = namedEntity[reference.slice(1, -1)]
|
||||||
|
return named === undefined ? undefined : { length: reference.length, text: named }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the oracle decodes every entity the reference HTML holds', () => {
|
||||||
|
for (const example of spec) {
|
||||||
|
for (const [reference] of example.html.matchAll(/&#?[0-9A-Za-z]+;/g)) {
|
||||||
|
assert.ok(decodeHtmlEntity(reference, 0) !== undefined, `example ${example.example} holds ${reference}, which the oracle would leave literal`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function referenceText(html: string): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
let preDepth = 0
|
||||||
|
let atBoundary = true
|
||||||
|
let skipNewline = false
|
||||||
|
for (let index = 0; index < html.length; index += 1) {
|
||||||
|
const character = html.charAt(index)
|
||||||
|
if (character === '<') {
|
||||||
|
const close = html.indexOf('>', index)
|
||||||
|
if (close === -1) break
|
||||||
|
const tag = html.slice(index, close + 1)
|
||||||
|
const name = tagName(tag)
|
||||||
|
if (name === 'br') {
|
||||||
|
parts.push(' ')
|
||||||
|
atBoundary = false
|
||||||
|
skipNewline = true
|
||||||
|
index = close
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (name === 'pre') {
|
||||||
|
if (tag.startsWith('</')) {
|
||||||
|
preDepth -= 1
|
||||||
|
trimTrailingNewline(parts)
|
||||||
|
} else {
|
||||||
|
preDepth += 1
|
||||||
|
}
|
||||||
|
atBoundary = true
|
||||||
|
} else {
|
||||||
|
atBoundary = blockTags.has(name)
|
||||||
|
}
|
||||||
|
index = close
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (character === '\n') {
|
||||||
|
if (skipNewline) {
|
||||||
|
skipNewline = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (preDepth > 0) {
|
||||||
|
parts.push('\n')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!atBoundary && !followedByBlock(html, index + 1)) parts.push(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const reference = decodeHtmlEntity(html, index)
|
||||||
|
if (reference !== undefined) {
|
||||||
|
parts.push(reference.text)
|
||||||
|
atBoundary = false
|
||||||
|
index += reference.length - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts.push(character)
|
||||||
|
atBoundary = false
|
||||||
|
}
|
||||||
|
return parts.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailingNewline(parts: string[]): void {
|
||||||
|
const last = parts[parts.length - 1]
|
||||||
|
if (last === undefined) return
|
||||||
|
parts[parts.length - 1] = last.endsWith('\n') ? last.slice(0, -1) : last
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newline beside a block open or close is a boundary rather than a soft break, so it spells no space.
|
||||||
|
function followedByBlock(html: string, index: number): boolean {
|
||||||
|
let next = index
|
||||||
|
while (next < html.length && (html[next] === '\n' || html[next] === ' ' || html[next] === '\t')) next += 1
|
||||||
|
if (next >= html.length) return true
|
||||||
|
if (html[next] !== '<') return false
|
||||||
|
const close = html.indexOf('>', next)
|
||||||
|
return close !== -1 && blockTags.has(tagName(html.slice(next, close + 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatenatedText(document: AdfNode): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
const pending: { inCode: boolean; node: AdfNode }[] = [{ inCode: false, node: document }]
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const frame = pending.pop()
|
||||||
|
if (frame === undefined) continue
|
||||||
|
const { inCode, node } = frame
|
||||||
|
if (node.text !== undefined) {
|
||||||
|
parts.push(inCode ? node.text : node.text.replace(/\n/g, ' '))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (node.type === 'hardBreak') {
|
||||||
|
parts.push(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const childInCode = inCode || node.type === 'codeBlock'
|
||||||
|
const content = node.content ?? []
|
||||||
|
for (let index = content.length - 1; index >= 0; index -= 1) {
|
||||||
|
const child = content[index]
|
||||||
|
if (child !== undefined) pending.push({ inCode: childInCode, node: child })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixpointRefused(example: SpecExample, document: AdfDocument): string | undefined {
|
||||||
|
const emitted = adfToMarkdown(document)
|
||||||
|
if (!emitted.ok) return emitted.error.code
|
||||||
|
const again = markdownToAdf(emitted.value)
|
||||||
|
assert.ok(again.ok, `example ${example.example} emits markdown it cannot read back`)
|
||||||
|
assert.deepEqual(again.value, document, `example ${example.example} does not hold its own round-trip`)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function textMismatch(example: SpecExample, document: AdfDocument): string | undefined {
|
||||||
|
const expected = referenceText(example.html)
|
||||||
|
const actual = concatenatedText(document)
|
||||||
|
return expected === actual ? undefined : `${JSON.stringify(expected)} against ${JSON.stringify(actual)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function countMismatch(example: SpecExample, document: AdfDocument): string | undefined {
|
||||||
|
const expected = referenceCounts(example.html)
|
||||||
|
const actual = nodeCounts(document)
|
||||||
|
const names = countKeys.filter((key) => expected[key] !== actual[key])
|
||||||
|
return names.length === 0 ? undefined : names.map((name) => `${name} ${expected[name]}/${actual[name]}`).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const example of spec) {
|
||||||
|
test(`CommonMark example ${example.example} => ${example.section}`, () => {
|
||||||
|
const parse = markdownToAdf(example.markdown)
|
||||||
|
const refused = exampleToRefusal.get(example.example)
|
||||||
|
if (refused !== undefined) {
|
||||||
|
assert.ok(!parse.ok, `example ${example.example} was expected to refuse with ${refused} but parsed`)
|
||||||
|
assert.equal(parse.error.code, refused, `example ${example.example} refused with a different code`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!parse.ok) assert.fail(`example ${example.example} was expected to parse but refused with ${parse.error.code}`)
|
||||||
|
|
||||||
|
const divergences: Record<Check, string | undefined> = {
|
||||||
|
count: countMismatch(example, parse.value),
|
||||||
|
fixpoint: fixpointRefused(example, parse.value),
|
||||||
|
text: textMismatch(example, parse.value),
|
||||||
|
}
|
||||||
|
for (const check of checks) {
|
||||||
|
const entry = exceptionIndex.get(`${example.example}:${check}`)
|
||||||
|
const divergence = divergences[check]
|
||||||
|
if (divergence === undefined) {
|
||||||
|
assert.equal(entry, undefined, `example ${example.example} passes its ${check} check but files an exception`)
|
||||||
|
} else {
|
||||||
|
assert.ok(entry !== undefined, `example ${example.example} ${check} check fails: ${divergence}`)
|
||||||
|
assert.equal(entry.divergence, divergence, `example ${example.example} ${check} diverged differently than filed`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync, readdirSync } from 'node:fs'
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
import { basename, dirname, join } from 'node:path'
|
import { basename, dirname, join, sep } from 'node:path'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
@@ -53,12 +53,13 @@ function pairedNames(root: string, first: string, second: string): string[] {
|
|||||||
function corpusJsonPaths(): string[] {
|
function corpusJsonPaths(): string[] {
|
||||||
return readdirSync(corpusRoot, { encoding: 'utf8', recursive: true })
|
return readdirSync(corpusRoot, { encoding: 'utf8', recursive: true })
|
||||||
.filter((name) => name.endsWith('.json'))
|
.filter((name) => name.endsWith('.json'))
|
||||||
|
.filter((name) => name !== `commonmark-spec${sep}spec.json`)
|
||||||
.map((name) => join(corpusRoot, name))
|
.map((name) => join(corpusRoot, name))
|
||||||
.sort()
|
.sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
test('every corpus directory is a kind the runner reads', () => {
|
test('every corpus directory is a kind the runner reads', () => {
|
||||||
assert.deepEqual(directoryNames(corpusRoot), ['errors', 'normalization', 'round-trip'])
|
assert.deepEqual(directoryNames(corpusRoot), ['commonmark-spec', 'errors', 'normalization', 'round-trip'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('every round-trip directory is a kind the runner reads', () => {
|
test('every round-trip directory is a kind the runner reads', () => {
|
||||||
|
|||||||
+18
-8
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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())
|
||||||
|
})
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.**
|
||||||
@@ -53,7 +53,7 @@ The numbering is the order the work was planned in, not the order it ships.
|
|||||||
- [x] **3i — The inline nodes and the marks.**
|
- [x] **3i — The inline nodes and the marks.**
|
||||||
- [x] **3j — The carry and the combinations.**
|
- [x] **3j — The carry and the combinations.**
|
||||||
- [ ] **3k — The CommonMark spec suite (`0.2.0`).** Checked in at `corpus/commonmark-spec/`,
|
- [ ] **3k — The CommonMark spec suite (`0.2.0`).** Checked in at `corpus/commonmark-spec/`,
|
||||||
pinned to the version it ships — the one `html-blocks.ts` names for its start
|
pinned to the version it ships — the one `commonmark-grammar.ts` names for its start
|
||||||
conditions — `corpus/README.md` gaining the kind.
|
conditions — `corpus/README.md` gaining the kind.
|
||||||
**Settled** (the maintainer, 2026-08-27): three checks an example must pass, the reference
|
**Settled** (the maintainer, 2026-08-27): three checks an example must pass, the reference
|
||||||
HTML each ships read as corpus data — which adds no format and no direction (§1). §2's
|
HTML each ships read as corpus data — which adds no format and no direction (§1). §2's
|
||||||
@@ -67,9 +67,11 @@ The numbering is the order the work was planned in, not the order it ships.
|
|||||||
counts close both. The exception list stays the maintainer's, and one entry is owed
|
counts close both. The exception list stays the maintainer's, and one entry is owed
|
||||||
already: 3h continues a list across the marker change CommonMark splits on, so an example
|
already: 3h continues a list across the marker change CommonMark splits on, so an example
|
||||||
the reference HTML gives two `<ul>` counts one `bulletList`. One outcome is no
|
the reference HTML gives two `<ul>` counts one `bulletList`. One outcome is no
|
||||||
exception and must not be filed as one: valid CommonMark parsing to a document
|
exception and must not be filed as one: a fixable §2 hole — valid CommonMark parsing to a
|
||||||
`adfToMarkdown` refuses is a §2 hole, which is what `corpus/unspellable/` held until 3c,
|
document `adfToMarkdown` refuses — which is what `corpus/unspellable/` held until 3c, 3e
|
||||||
3e and 3h landed their answers and emptied it.
|
and 3h landed their answers and emptied it. The permanent ones — a link destination or
|
||||||
|
title no escape spells, a paragraph opening with a code span — are the exceptions, named
|
||||||
|
by AGENTS.md §2.
|
||||||
- [ ] **4 — Round-trip property tests (`0.2.0`)**, widening 3j's corpus round-trip past the
|
- [ ] **4 — Round-trip property tests (`0.2.0`)**, widening 3j's corpus round-trip past the
|
||||||
documents a human wrote — the thing that proves 2 and 3 beyond them. Editor-normal (§2) is
|
documents a human wrote — the thing that proves 2 and 3 beyond them. Editor-normal (§2) is
|
||||||
finished here, on 3i's merging — `toEditorNormal(doc)` and the equality the round-trip
|
finished here, on 3i's merging — `toEditorNormal(doc)` and the equality the round-trip
|
||||||
@@ -84,7 +86,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 +97,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 +114,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.
|
||||||
- [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret,
|
- [ ] **4d — What 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.4–7.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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user