Compare commits
42 Commits
v0.1.0
...
a581e454ae
| Author | SHA1 | Date | |
|---|---|---|---|
| a581e454ae | |||
| 342c2027d8 | |||
| fade9f51ea | |||
| b6930b45a4 | |||
| e166e969cb | |||
| 507f68aa13 | |||
| a2672bdc5b | |||
| 9303235434 | |||
| eafd863a32 | |||
| 1125715371 | |||
| 31156f4914 | |||
| 0185879943 | |||
| 9cd4b33020 | |||
| 758a5724e9 | |||
| 276433d635 | |||
| 7672531cdc | |||
| ec5f80e47f | |||
| da58a59f70 | |||
| 2796487b30 | |||
| dcea401ec2 | |||
| 25d851cc67 | |||
| 0f03edc8ef | |||
| 37f59cec40 | |||
| aa34d6e7fd | |||
| 3c1bacace6 | |||
| f8a5d5e9b3 | |||
| c9fb85f9db | |||
| 24b2e8cbbe | |||
| f66efabcbb | |||
| e424b35fd6 | |||
| 5c62b07530 | |||
| 1e20b0b6cc | |||
| e989cc3edd | |||
| f81bebe8d0 | |||
| d1331a6612 | |||
| 6e3b7e692c | |||
| dfbb92b7ab | |||
| 12b57a06a9 | |||
| e6610d7057 | |||
| 026ea5e1b6 | |||
| 5d19bdbae7 | |||
| 75f35eef0b |
@@ -17,12 +17,14 @@ When losslessness and readability conflict, losslessness wins.
|
|||||||
|
|
||||||
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
|
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
|
||||||
back yields the library's canonical spelling, and that spelling round-trips byte-identically —
|
back yields the library's canonical spelling, and that spelling round-trips byte-identically —
|
||||||
where there is a way back. CommonMark spells link destinations the flavour has no escape for, so a
|
where there is a way back. CommonMark spells some things the flavour has no escape for — a link
|
||||||
parse succeeding does not imply a spellable document; `todo.md` 3k's exception list names those.
|
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
|
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
|
||||||
key — the only domain markdown can restore.
|
array the absent key — the only domain markdown can restore.
|
||||||
|
|
||||||
Round-trip equality is a property tested over a corpus, not a claim made in prose.
|
Round-trip equality is a property tested over a corpus, not a claim made in prose.
|
||||||
|
|
||||||
@@ -57,8 +59,16 @@ 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. Atlassian's ADF JSON Schemas ship
|
||||||
|
vendored the same way, at `spec/adf-schema/`, rather than as the `@atlaskit/adf-schema` dev
|
||||||
|
dependency — CommonJS-only, some fifty packages with React among them, and a release most days for
|
||||||
|
Renovate to automerge — re-pinned by hand when a payload or a report shows the need.
|
||||||
|
`devDependencies`: few, each earning its keep; they never reach a consumer. `fast-check` earns its
|
||||||
|
place shrinking a failing generated document to the nodes that break it.
|
||||||
|
|
||||||
## 6. The package contract
|
## 6. The package contract
|
||||||
|
|
||||||
@@ -198,7 +208,8 @@ resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` s
|
|||||||
`node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor.
|
`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`
|
A fourth engine reads the build rather than the source: a headless Firefox loads `dist/index.js`
|
||||||
over HTTP and converts the whole corpus, which is §6's browser half and the only SpiderMonkey
|
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.
|
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
|
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
|
one network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such
|
||||||
@@ -213,8 +224,11 @@ functions, and a branch floor that only ever moves upward. It sits below 100 bec
|
|||||||
compared against `undefined` — have a half no valid document reaches.
|
compared against `undefined` — have a half no valid document reaches.
|
||||||
|
|
||||||
The corpus, all checked in: hand-built fixtures per node and combination; real sanitized ADF from
|
The corpus, all checked in: hand-built fixtures per node and combination; real sanitized ADF from
|
||||||
live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite against
|
live Atlassian APIs; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`.
|
||||||
`markdownToAdf` and `markdownToHtml`.
|
|
||||||
|
Beside the corpus, properties run over documents generated from the node tables, on a fixed seed in
|
||||||
|
the gate; `PROPERTY_RUNS=<runs>` raises the runs and randomizes the seed for local digging, and a
|
||||||
|
counterexample found becomes a round-trip fixture.
|
||||||
|
|
||||||
`spec/flavour.md` is read as a source too, so the node tables cannot drift from the prose they
|
`spec/flavour.md` is read as a source too, so the node tables cannot drift from the prose they
|
||||||
copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes
|
copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes
|
||||||
@@ -224,6 +238,12 @@ of a bullet; fenced examples are skipped. It guards the attributes alone: nodes
|
|||||||
content model share a bullet, and the argument attribute is spelled ahead of `Attributes: `, so
|
content model share a bullet, and the argument attribute is spelled ahead of `Attributes: `, so
|
||||||
both answer to the round-trip corpus and to nothing else where a node has no fixture.
|
both answer to the round-trip corpus and to nothing else where a node has no fixture.
|
||||||
|
|
||||||
|
The tables answer to Atlassian's schema too (§5): for every node and mark they spell, the attribute
|
||||||
|
names and kinds equal what `full.json` and `stage-0.json` hold between them. Value sets stay
|
||||||
|
documentation, since any value round-trips. What the schema holds and the tables do not spell is
|
||||||
|
pinned by name — an attribute as a gap, a type as carried — so a re-pin adding either goes red until
|
||||||
|
someone spells it or pins it.
|
||||||
|
|
||||||
## 11. Code rules
|
## 11. Code rules
|
||||||
|
|
||||||
- Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order
|
- Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order
|
||||||
@@ -316,17 +336,16 @@ worth deliberating; what matters is that nothing is left undone in the end. Per
|
|||||||
gate result (commit and outcome); a reviewer does not re-run `ci.sh` or the tests when a
|
gate result (commit and outcome); a reviewer does not re-run `ci.sh` or the tests when a
|
||||||
result exists for the commit under review, or when the diff since that result cannot affect
|
result exists for the commit under review, or when the diff since that result cannot affect
|
||||||
it (docs-only) — re-run only what its own findings or fixes invalidate.
|
it (docs-only) — re-run only what its own findings or fixes invalidate.
|
||||||
3. Merge the PR (standing authorization, this repo only, granted through the `0.1.0` release —
|
3. Merge the PR (standing authorization, this repo only, granted through the `0.2.0` release —
|
||||||
PR #3), check the box in `todo.md` and move the item's text to `todo-history.md`, leaving its
|
the maintainer, 2026-09-13), check the box in `todo.md` and move the item's text to
|
||||||
title behind, report, stop. The next chunk gets a fresh session.
|
`todo-history.md`, leaving its title behind, report, stop. The next chunk gets a fresh session.
|
||||||
|
|
||||||
Ask, don't guess: any choice where what the maintainer would pick is not near-certain gets asked,
|
Ask, don't guess: any choice where what the maintainer would pick is not near-certain gets asked,
|
||||||
and the answer lands as a decision in this file. The confidence bar is very high — asking too
|
and the answer lands as a decision in this file. The confidence bar is very high — asking too
|
||||||
often is the accepted cost, guessing wrong is not.
|
often is the accepted cost, guessing wrong is not.
|
||||||
|
|
||||||
Reserved for the maintainer, never the agent: changing `version` in `package.json` (a bump on
|
Reserved for the maintainer, never the agent: changing `version` in `package.json` (a bump on
|
||||||
`main` publishes, §9 — every release including `0.1.0` is the maintainer's), making the repo
|
`main` publishes, §9 — every release is the maintainer's) and the `NPM_TOKEN` secret.
|
||||||
public, and creating the `NPM_TOKEN` secret.
|
|
||||||
|
|
||||||
A continuous loop session (`/loop`) counts as a chain of sessions: one chunk per iteration, each
|
A continuous loop session (`/loop`) counts as a chain of sessions: one chunk per iteration, each
|
||||||
iteration starting by re-reading `AGENTS.md` and `todo.md` and trusting them over anything
|
iteration starting by re-reading `AGENTS.md` and `todo.md` and trusting them over anything
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
@@ -115,6 +115,11 @@ emit refuses:
|
|||||||
library's canonical spelling, which round-trips byte-identically — where it converts back at
|
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.
|
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.
|
`[a](/a\b)`, `<http://x?a=1&b=2>` and `[a](/x y)` read cleanly and then refuse.
|
||||||
|
- Three CommonMark spellings parse without an error and build a document the reference renders
|
||||||
|
differently: `[](/url)` and `[]()` stay literal text against CommonMark's empty link, a list
|
||||||
|
continuing past a marker change stays one list against CommonMark's two, and a shortcut
|
||||||
|
reference matching its definition only under Unicode case folding stays unresolved. Each is
|
||||||
|
pinned `pending` in `corpus/commonmark-spec/exceptions.json`.
|
||||||
- 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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
|||||||
import { readFileSync, readdirSync } from 'node:fs'
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
import { extname, join } from 'node:path'
|
import { extname, join } from 'node:path'
|
||||||
|
import { toEditorNormal } from '../dist/adf/editor-normal.js'
|
||||||
|
|
||||||
const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
|
const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
|
||||||
const driver = 'http://127.0.0.1:4444'
|
const driver = 'http://127.0.0.1:4444'
|
||||||
@@ -72,6 +73,7 @@ assert.deepEqual(
|
|||||||
readdirSync(corpusRoot, { withFileTypes: true })
|
readdirSync(corpusRoot, { withFileTypes: true })
|
||||||
.filter((entry) => entry.isDirectory())
|
.filter((entry) => entry.isDirectory())
|
||||||
.map((entry) => entry.name)
|
.map((entry) => entry.name)
|
||||||
|
.filter((name) => name !== 'commonmark-spec')
|
||||||
.sort(),
|
.sort(),
|
||||||
['errors', 'normalization', 'round-trip'],
|
['errors', 'normalization', 'round-trip'],
|
||||||
'a corpus kind the browser leg does not convert',
|
'a corpus kind the browser leg does not convert',
|
||||||
@@ -104,7 +106,7 @@ for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) {
|
|||||||
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
|
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
|
||||||
assert.equal(result.emitted.value, markdown)
|
assert.equal(result.emitted.value, markdown)
|
||||||
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
|
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
|
||||||
assert.deepEqual(result.parsed.value, JSON.parse(json))
|
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(json))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +114,7 @@ for (const [index, { name }] of corpus.normalization.entries()) {
|
|||||||
const result = results.normalization[index]
|
const result = results.normalization[index]
|
||||||
checking(name, result, () => {
|
checking(name, result, () => {
|
||||||
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
|
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
|
||||||
assert.deepEqual(result.parsed.value, JSON.parse(fixture(name, '.json')))
|
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(fixture(name, '.json')))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ if printf '%s' "$test_output" | grep -q 'ℹ tests 0'; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
in_image "$deno_image" deno test --allow-read --no-check src/
|
in_image "$deno_image" deno test --allow-env=PROPERTY_RUNS --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" npm run build
|
||||||
|
|||||||
+11
-1
@@ -12,5 +12,15 @@ 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 from
|
||||||
|
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John
|
||||||
|
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), 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,57 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"localId": "01a0a067-68cf-78af-abd6-c660ec0d189b"
|
||||||
|
},
|
||||||
|
"text": "Owner",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": " signs off.",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Signed by ",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"localId": "01a0a067-68d2-787e-afc5-2459776aa029"
|
||||||
|
},
|
||||||
|
"text": "the owner",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"localId": "01a0a067-68d5-7372-9962-29a039654056"
|
||||||
|
},
|
||||||
|
"text": "One ",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"localId": "01a0a067-68d5-7372-9962-29a039654056"
|
||||||
|
},
|
||||||
|
"text": "anchor",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68cf-78af-abd6-c660ec0d189b\"},\"text\":\"Owner\",\"type\":\"text\"}"} signs off.
|
||||||
|
|
||||||
|
Signed by :adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d2-787e-afc5-2459776aa029\"},\"text\":\"the owner\",\"type\":\"text\"}"}
|
||||||
|
|
||||||
|
:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d5-7372-9962-29a039654056\"},\"text\":\"One \",\"type\":\"text\"}"}:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d5-7372-9962-29a039654056\"},\"text\":\"anchor\",\"type\":\"text\"}"}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "http://a`b"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "http://a`b",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "`c",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "http://d`e"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "http://d`e",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
:underline[[http://a\`b](http://a\`b)]`c
|
||||||
|
|
||||||
|
:underline[[http://d\`e](http://d`e)]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"color": ""
|
||||||
|
},
|
||||||
|
"type": "textColor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "`"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": " ",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "`a",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "c",
|
||||||
|
"title": "`"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "b",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "c",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "e`f"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "d",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:textColor[[ ](\`)]{color=""}`a
|
||||||
|
|
||||||
|
:underline[[b](c "\`")]`c`
|
||||||
|
|
||||||
|
:underline[[d](e`f)]
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "subsup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "["
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "a",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "]"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "b",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": " ",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "x",
|
||||||
|
"title": "["
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "c",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "e[f]"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "d",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "http://a[b"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "http://a[b",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
:subsup[[a](\[)]
|
||||||
|
|
||||||
|
:underline[[b](\]) [c](x "\[")]
|
||||||
|
|
||||||
|
:underline[[d](e[f])]
|
||||||
|
|
||||||
|
:underline[[http://a\[b](http://a\[b)]
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": ":a{"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "x",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "y",
|
||||||
|
"title": ":a{"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "x",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": ":a[]{"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "x",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "https://example.com/?q=:emoji{"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "x",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "ab:c{"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "ab:c{",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
:underline[[x](\:a{)]
|
||||||
|
|
||||||
|
:underline[[x](y "\:a{")]
|
||||||
|
|
||||||
|
:underline[[x](\:a[]{)]
|
||||||
|
|
||||||
|
:underline[[x](https://example.com/?q=\:emoji{)]
|
||||||
|
|
||||||
|
:underline[[ab\:c{](ab\:c{)]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": " \na",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "codeBlock"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "listItem"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "bulletList"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"order": 1
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "\t",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "codeBlock"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "listItem"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "bulletList"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "listItem"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "orderedList"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
::::bulletList
|
||||||
|
:::listItem
|
||||||
|
```
|
||||||
|
|
||||||
|
a
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
::::
|
||||||
|
|
||||||
|
::::::orderedList {order=1}
|
||||||
|
:::::listItem
|
||||||
|
::::bulletList
|
||||||
|
:::listItem
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
::::
|
||||||
|
:::::
|
||||||
|
::::::
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "",
|
||||||
|
"title": ""
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "a",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[a](<> "")
|
||||||
+1
-1
@@ -7,7 +7,7 @@ node_image=node:24.19.0-alpine3.24
|
|||||||
in_image() {
|
in_image() {
|
||||||
local image=$1 entrypoint=$2
|
local image=$1 entrypoint=$2
|
||||||
shift 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" "$@"
|
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS:+-e PROPERTY_RUNS} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
with_firefox() {
|
with_firefox() {
|
||||||
|
|||||||
Generated
+41
@@ -10,6 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
|
"fast-check": "4.10.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -366,6 +367,46 @@
|
|||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-check": {
|
||||||
|
"version": "4.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
|
||||||
|
"integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pure-rand": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pure-rand": {
|
||||||
|
"version": "8.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||||
|
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/dubzzz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fast-check"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
|
"fast-check": "4.10.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Copyright 2019 Atlassian Pty Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Atlassian's ADF JSON Schemas
|
||||||
|
|
||||||
|
`full.json` and `stage-0.json` are byte-exact from `dist/json-schema/v1/` in
|
||||||
|
[`@atlaskit/adf-schema` 57.4.9](https://registry.npmjs.org/@atlaskit/adf-schema/-/adf-schema-57.4.9.tgz)
|
||||||
|
(© Atlassian Pty Ltd, Apache-2.0: `LICENSE` is the package's own notice, `LICENSE-2.0.txt` the
|
||||||
|
licence it names).
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+21
-16
@@ -28,7 +28,8 @@ normalizes to it through the round-trip.
|
|||||||
adjacent lists of a kind back as one. The leaf `::listBreak` parts them, taking the separation
|
adjacent lists of a kind back as one. The leaf `::listBreak` parts them, taking the separation
|
||||||
any directive block takes where it sits. It builds no node, and it reads only between two
|
any directive block takes where it sits. It builds no node, and it reads only between two
|
||||||
adjacent lists of one type: elsewhere, or carrying an argument, `{attrs}` or a body, it is a
|
adjacent lists of one type: elsewhere, or carrying an argument, `{attrs}` or a body, it is a
|
||||||
named error.
|
named error. A list whose item holds a line of spaces or tabs alone, which a list item reads
|
||||||
|
back empty, takes the directive form.
|
||||||
- Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`.
|
- Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`.
|
||||||
- ATX headings (`#` … `######`); setext input normalizes to ATX.
|
- ATX headings (`#` … `######`); setext input normalizes to ATX.
|
||||||
- Code fences ``` with the node's language as info string, the fence lengthened past any backtick
|
- Code fences ``` with the node's language as info string, the fence lengthened past any backtick
|
||||||
@@ -43,10 +44,12 @@ normalizes to it through the round-trip.
|
|||||||
CommonMark admits no spelling — the end of a block, inside an ATX heading — or where the node
|
CommonMark admits no spelling — the end of a block, inside an ATX heading — or where the node
|
||||||
carries an attribute, it is the inline directive.
|
carries an attribute, it is the inline directive.
|
||||||
- An empty paragraph — real payloads carry them — is `::paragraph`.
|
- An empty paragraph — real payloads carry them — is `::paragraph`.
|
||||||
- Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes. A
|
- Links `[text](url)`; `<…>` around a destination containing spaces, `<>` an empty one beside a
|
||||||
backslash escapes a parenthesis the destination leaves unbalanced, and a quote inside the title;
|
title; title in double quotes. A backslash escapes a parenthesis the destination leaves
|
||||||
a balanced pair stays bare. `<url>` autolink form only when the text equals the destination and
|
unbalanced, and a quote inside the title; a balanced pair stays bare. `<url>` autolink form only
|
||||||
the destination is a valid CommonMark autolink (absolute URI).
|
when the text equals the destination and the destination is a valid CommonMark autolink
|
||||||
|
(absolute URI) — inside an inline directive's `[content]`, one holding no backtick, no
|
||||||
|
unbalanced bracket and no inline directive opener.
|
||||||
- Paragraphs on one line — no soft wrapping; a soft line break in input becomes a single space.
|
- Paragraphs on one line — no soft wrapping; a soft line break in input becomes a single space.
|
||||||
- Entity references in input decode to their characters; output backslash-escapes only where text
|
- Entity references in input decode to their characters; output backslash-escapes only where text
|
||||||
would otherwise parse as syntax, scanning the assembled line rather than each text node: escape
|
would otherwise parse as syntax, scanning the assembled line rather than each text node: escape
|
||||||
@@ -128,9 +131,11 @@ quoted where bare carries it, an escape longer than it need be, an empty `{attrs
|
|||||||
each a named error naming the spelling to write instead.
|
each a named error naming the spelling to write instead.
|
||||||
|
|
||||||
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
|
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
|
||||||
directive syntax — the leading `:` of a would-be directive, `]` inside content, a `{` right
|
directive syntax — the leading `:` of a would-be directive, `]` inside content, a bracket a link's
|
||||||
after a directive's closing `]`, which would otherwise be read as the attributes it has none
|
destination and title inside content leave unbalanced, a backtick there that would open a code span
|
||||||
of; outside code spans and code blocks, a backslash before `:` in input yields a literal colon.
|
and a `:` there that would open an inline directive, a `{` right after a directive's closing `]`,
|
||||||
|
which would otherwise be read as the attributes it has none of; outside code spans and code blocks,
|
||||||
|
a backslash before `:` in input yields a literal colon.
|
||||||
|
|
||||||
**Malformed directives are error results**, named: an unclosed container at end of input, a body
|
**Malformed directives are error results**, named: an unclosed container at end of input, a body
|
||||||
fence line of the container's length or longer, a bare colon-run line outside any container or
|
fence line of the container's length or longer, a bare colon-run line outside any container or
|
||||||
@@ -404,10 +409,10 @@ Right.
|
|||||||
Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of
|
Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of
|
||||||
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot
|
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot
|
||||||
as plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
|
as plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
|
||||||
parsing to anything but one unmarked text node — adjacent identical-mark text nodes merged first —
|
parsing to anything but one unmarked text node — adjacent text nodes with identical marks and no
|
||||||
is a named error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach
|
attributes merged first — is a named error, and so is a `text` key in `{attrs}`. An enclosing mark
|
||||||
into the slot. The rest take no content, `:text` included; content on a node that takes none is a
|
spelling does not reach into the slot. The rest take no content, `:text` included; content on a
|
||||||
named error.
|
node that takes none is a named error.
|
||||||
|
|
||||||
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
|
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
|
||||||
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
|
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
|
||||||
@@ -434,10 +439,10 @@ CommonMark strips or refuses one — a block's inline content edges, either side
|
|||||||
an em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled
|
an em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled
|
||||||
`:text{text="…"}`, the reserved key carrying the node's text, escaped by the attribute grammar
|
`:text{text="…"}`, the reserved key carrying the node's text, escaped by the attribute grammar
|
||||||
and never literal: pipe cells trim and pad. The emitter wraps the whitespace run alone and leaves
|
and never literal: pipe cells trim and pad. The emitter wraps the whitespace run alone and leaves
|
||||||
the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks
|
the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks and no
|
||||||
(AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and tabs, or one
|
attributes (AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and
|
||||||
run of newlines, and anything else — a mixed run, or text CommonMark carries plainly — is a named
|
tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries plainly —
|
||||||
error.
|
is a named error.
|
||||||
|
|
||||||
```
|
```
|
||||||
:text{text=" "}Two leading spaces held, and one text node split:text{text="\n"}over two lines.
|
:text{text=" "}Two leading spaces held, and one text node split:text{text="\n"}over two lines.
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import fc from 'fast-check'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { env } from 'node:process'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
|
||||||
|
import type { Arbitrary } from 'fast-check'
|
||||||
|
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
||||||
|
import type { JsonValue } from './json-value.ts'
|
||||||
|
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||||
|
import { blockArgument } from './markdown/block-directive-arguments.ts'
|
||||||
|
import { blockDirectives } from './adf/block-directives.ts'
|
||||||
|
import { inlineDirectives } from './adf/inline-directives.ts'
|
||||||
|
import { markAttributes } from './adf/mark-attributes.ts'
|
||||||
|
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||||
|
import { toEditorNormal } from './adf/editor-normal.ts'
|
||||||
|
|
||||||
|
type Positions = { block: AdfNode; inline: AdfNode }
|
||||||
|
|
||||||
|
const deepRunsVariable = 'PROPERTY_RUNS'
|
||||||
|
const gateRuns = 1600
|
||||||
|
const gateSeed = 20260914
|
||||||
|
// Bun's test runner stops a test after five seconds unless the test sets its own timeout.
|
||||||
|
const propertyTimeout = 600000
|
||||||
|
|
||||||
|
const depthIdentifier = fc.createDepthIdentifier()
|
||||||
|
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
|
||||||
|
const flatCommonMarkShapeWeight = 4
|
||||||
|
const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉', ':a[', ':a{', 'ab:', 'http://')
|
||||||
|
const nestingCommonMarkShapeWeight = 21
|
||||||
|
const spelledTypes = new Set(['text', ...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes)])
|
||||||
|
|
||||||
|
function textOf(minLength: number): Arbitrary<string> {
|
||||||
|
return fc.oneof(
|
||||||
|
{ arbitrary: fc.string({ maxLength: 12, minLength, unit: markdownPieces }), weight: 4 },
|
||||||
|
{ arbitrary: fc.string({ maxLength: 6, minLength, unit: 'grapheme' }), weight: 1 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = textOf(1)
|
||||||
|
const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type))
|
||||||
|
const numberValue = fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 })
|
||||||
|
|
||||||
|
// V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected.
|
||||||
|
const keyPiece = fc
|
||||||
|
.oneof({ arbitrary: markdownPieces, weight: 4 }, { arbitrary: fc.string({ maxLength: 1, minLength: 1, unit: 'grapheme' }), weight: 1 })
|
||||||
|
.filter((piece) => !/[\\"\x00-\x1f]/.test(piece))
|
||||||
|
const jsonKey = fc.string({ maxLength: 8, unit: keyPiece })
|
||||||
|
|
||||||
|
const { jsonValue } = fc.letrec<{ jsonValue: JsonValue }>((tie) => ({
|
||||||
|
jsonValue: fc.oneof(
|
||||||
|
{ depthSize: 'small', maxDepth: 2 },
|
||||||
|
fc.oneof(fc.constant(null), fc.boolean(), numberValue, textOf(0)),
|
||||||
|
fc.array(tie('jsonValue'), { maxLength: 3 }),
|
||||||
|
fc.dictionary(jsonKey, tie('jsonValue'), { maxKeys: 3, noNullPrototype: true }),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const valueByKind: Readonly<Record<AttributeKind, Arbitrary<JsonValue>>> = {
|
||||||
|
boolean: fc.boolean(),
|
||||||
|
json: jsonValue,
|
||||||
|
number: numberValue,
|
||||||
|
string: textOf(0),
|
||||||
|
}
|
||||||
|
|
||||||
|
function attributes(vocabulary: AttributeVocabulary): Arbitrary<AdfAttributes> {
|
||||||
|
const model = Object.fromEntries(
|
||||||
|
Object.entries(vocabulary).map(([key, kind]) => [key, fc.oneof({ arbitrary: fc.constant(undefined), weight: 2 }, { arbitrary: valueByKind[kind], weight: 1 })]),
|
||||||
|
)
|
||||||
|
return fc.record(model).map(heldAttributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
function heldAttributes(held: Readonly<Record<string, JsonValue | undefined>>): AdfAttributes {
|
||||||
|
const attrs: AdfAttributes = {}
|
||||||
|
for (const [key, value] of Object.entries(held)) if (value !== undefined) attrs[key] = value
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
|
||||||
|
const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))]
|
||||||
|
return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mark: Arbitrary<AdfMark> = fc.oneof(
|
||||||
|
{ arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 },
|
||||||
|
{ arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 },
|
||||||
|
)
|
||||||
|
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
|
||||||
|
|
||||||
|
const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' }))
|
||||||
|
|
||||||
|
const autolinkTextNode = fc
|
||||||
|
.record({ href: fc.tuple(fc.constantFrom('ab:', 'http://'), textOf(0)).map(([scheme, rest]) => `${scheme}${rest}`), marks })
|
||||||
|
.map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' }))
|
||||||
|
|
||||||
|
const inlineNodes = Object.entries(inlineDirectives).map(([type, directive]) =>
|
||||||
|
fc.record({ attrs: attributes(directive.attributes), marks }).map((held): AdfNode => ({ ...held, type })),
|
||||||
|
)
|
||||||
|
|
||||||
|
function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): { arbitrary: Arbitrary<AdfNode>; weight: number }[] {
|
||||||
|
return arbitraries.map((arbitrary) => ({ arbitrary, weight }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = fc.letrec<Positions>((tie) => {
|
||||||
|
const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 })
|
||||||
|
const inlineContent = fc.array(tie('inline'), { depthIdentifier, maxLength: 4 })
|
||||||
|
const contentByModel = {
|
||||||
|
block: blockContent,
|
||||||
|
code: fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 }),
|
||||||
|
inline: inlineContent,
|
||||||
|
none: fc.constant<AdfNode[]>([]),
|
||||||
|
}
|
||||||
|
const blockMarks = fc.oneof({ arbitrary: fc.constant<AdfMark[]>([]), weight: 4 }, { arbitrary: marks, weight: 1 })
|
||||||
|
const blockNodes = Object.entries(blockDirectives).map(([type, directive]) => {
|
||||||
|
const argument = blockArgument(type)
|
||||||
|
const vocabulary: AttributeVocabulary = argument === undefined ? directive.attributes : { ...directive.attributes, [argument]: 'string' }
|
||||||
|
const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[directive.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type }))
|
||||||
|
return { leaf: directive.contentModel === 'code' || directive.contentModel === 'none', node }
|
||||||
|
})
|
||||||
|
const unknownNode = fc
|
||||||
|
.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType })
|
||||||
|
.map((held): AdfNode => held)
|
||||||
|
const leafBlocks = blockNodes.filter((entry) => entry.leaf).map((entry) => entry.node)
|
||||||
|
const containerBlocks = blockNodes.filter((entry) => !entry.leaf).map((entry) => entry.node)
|
||||||
|
const misplacedWeight = 7
|
||||||
|
const paragraph = inlineContent.map((content): AdfNode => ({ content, type: 'paragraph' }))
|
||||||
|
const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type }))
|
||||||
|
const listItems = fc.array(
|
||||||
|
blockContent.map((content): AdfNode => ({ content, type: 'listItem' })),
|
||||||
|
{ depthIdentifier, maxLength: 3, minLength: 1 },
|
||||||
|
)
|
||||||
|
const flatCommonMarkShapes = [
|
||||||
|
fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })),
|
||||||
|
paragraph,
|
||||||
|
fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable),
|
||||||
|
]
|
||||||
|
const nestingCommonMarkShapes = [
|
||||||
|
blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })),
|
||||||
|
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
|
||||||
|
fc
|
||||||
|
.record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) })
|
||||||
|
.map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })),
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
block: fc.oneof(
|
||||||
|
{ depthIdentifier, depthSize: 'small', maxDepth: 4 },
|
||||||
|
{
|
||||||
|
arbitrary: fc.oneof(...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)),
|
||||||
|
weight: leafBlocks.length * 2 + flatCommonMarkShapes.length * flatCommonMarkShapeWeight,
|
||||||
|
},
|
||||||
|
{ arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 },
|
||||||
|
{ arbitrary: fc.oneof(textNode, ...inlineNodes, unknownNode), weight: misplacedWeight },
|
||||||
|
{ arbitrary: fc.oneof(...nestingCommonMarkShapes), weight: nestingCommonMarkShapes.length * nestingCommonMarkShapeWeight },
|
||||||
|
),
|
||||||
|
inline: fc.oneof(
|
||||||
|
{ depthIdentifier, depthSize: 'small', maxDepth: 4 },
|
||||||
|
{ arbitrary: textNode, weight: 12 },
|
||||||
|
{ arbitrary: autolinkTextNode, weight: 2 },
|
||||||
|
{ arbitrary: fc.oneof(...inlineNodes), weight: 7 },
|
||||||
|
{ arbitrary: fc.oneof(...blockNodes.map((entry) => entry.node), unknownNode), weight: 2 },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const adfDocument = fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => toEditorNormal({ content, type: 'doc', version: 1 }))
|
||||||
|
|
||||||
|
function runParameters(): { numRuns: number; seed?: number } {
|
||||||
|
const deepRuns = env[deepRunsVariable]
|
||||||
|
if (deepRuns === undefined) return { numRuns: gateRuns, seed: gateSeed }
|
||||||
|
assert.ok(/^[1-9]\d*$/.test(deepRuns), `${deepRunsVariable} is a run count in digits, such as ${deepRunsVariable}=10000: found ${JSON.stringify(deepRuns)}`)
|
||||||
|
return { numRuns: Number(deepRuns) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(adfDocument, (document) => {
|
||||||
|
const emitted = adfToMarkdown(document)
|
||||||
|
if (!emitted.ok) return
|
||||||
|
const read = markdownToAdf(emitted.value)
|
||||||
|
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
||||||
|
assert.deepEqual(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`)
|
||||||
|
}),
|
||||||
|
runParameters(),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
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 { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
||||||
|
import { blockDirectives } from './adf/block-directives.ts'
|
||||||
|
import { inlineDirectives } from './adf/inline-directives.ts'
|
||||||
|
import { markAttributes } from './adf/mark-attributes.ts'
|
||||||
|
import { blockArgument } from './markdown/block-directive-arguments.ts'
|
||||||
|
|
||||||
|
type Held = Map<string, Set<AttributeKind>>
|
||||||
|
type Properties = Map<string, SchemaObject[]>
|
||||||
|
type SchemaObject = Readonly<Record<string, unknown>>
|
||||||
|
type Spelled = [string, Map<string, AttributeKind>]
|
||||||
|
|
||||||
|
const carried = ['alignment', 'annotation', 'backgroundColor', 'blockCard', 'bodiedRule', 'breakout', 'dataConsumer', 'embedCard', 'fontSize', 'fragment', 'indentation', 'inlineExtension', 'placeholder']
|
||||||
|
const definitionReference = '#/definitions/'
|
||||||
|
const gaps = ['layoutSection.columnRuleStyle', 'link.collection', 'link.id', 'link.occurrenceKey', 'rule.color', 'rule.style', 'rule.weight']
|
||||||
|
const grammarOwn = ['doc', 'text']
|
||||||
|
const readKeywords = ['$ref', 'additionalProperties', 'allOf', 'anyOf', 'enum', 'items', 'maxItems', 'maximum', 'minItems', 'minLength', 'minimum', 'pattern', 'properties', 'required', 'type']
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'adf-schema')
|
||||||
|
const schemaFiles = ['full.json', 'stage-0.json']
|
||||||
|
|
||||||
|
test('the ADF JSON Schemas are @atlaskit/adf-schema 57.4.9, vendored byte-exact', () => {
|
||||||
|
const digest = (name: string) => createHash('sha256').update(readFileSync(join(root, name))).digest('hex')
|
||||||
|
assert.equal(digest('full.json'), '75f080928a970250eb8289e9cae5374e3c2a6c0ac3ca22478acaa9d3f39484a3')
|
||||||
|
assert.equal(digest('stage-0.json'), '56747e5a71c0d5f8c58f94180d69e4482f62c08020d66aaf327333681fcc1c8f')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the tables spell the attribute names and kinds the ADF JSON Schemas give each type they spell, the pinned gaps apart', () => {
|
||||||
|
const held = schemaTypes()
|
||||||
|
const spelledTypes = spelled()
|
||||||
|
const found: string[] = []
|
||||||
|
const gapsHeld = new Set<string>()
|
||||||
|
for (const [type, spelledKinds] of spelledTypes) {
|
||||||
|
const kinds = held.get(type)
|
||||||
|
if (kinds === undefined) {
|
||||||
|
found.push(`${type}: the tables spell the type, the schema holds no definition of it`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const [attribute, schemaKinds] of kinds) {
|
||||||
|
const kind = spelledKinds.get(attribute)
|
||||||
|
const holds = [...schemaKinds].sort().join(' or ')
|
||||||
|
if (kind === undefined && gaps.includes(`${type}.${attribute}`)) gapsHeld.add(`${type}.${attribute}`)
|
||||||
|
else if (kind === undefined) found.push(`${type}.${attribute}: the schema holds ${holds}, the tables spell nothing and the gaps list does not name it`)
|
||||||
|
else if (schemaKinds.size !== 1 || !schemaKinds.has(kind)) found.push(`${type}.${attribute}: the tables spell ${kind}, the schema holds ${holds}`)
|
||||||
|
}
|
||||||
|
for (const [attribute, kind] of spelledKinds) if (!kinds.has(attribute)) found.push(`${type}.${attribute}: the tables spell ${kind}, the schema holds nothing`)
|
||||||
|
}
|
||||||
|
for (const gap of gaps) {
|
||||||
|
if (gapsHeld.has(gap)) continue
|
||||||
|
const [type = '', attribute = ''] = gap.split('.')
|
||||||
|
const spelledKinds = spelledTypes.find(([name]) => name === type)?.[1]
|
||||||
|
const kind = spelledKinds?.get(attribute)
|
||||||
|
if (spelledKinds === undefined) found.push(`${gap}: the gaps list names it, the tables spell no ${type} type`)
|
||||||
|
else if (kind === undefined) found.push(`${gap}: the gaps list names it, the schema holds nothing`)
|
||||||
|
else found.push(`${gap}: the gaps list names it, the tables spell ${kind}`)
|
||||||
|
}
|
||||||
|
assert.deepEqual(found, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("the ADF JSON Schemas hold no type the tables leave unspelled, the pinned carried ones and the grammar's own apart", () => {
|
||||||
|
const held = schemaTypes()
|
||||||
|
const spelledNames = new Set(spelled().map(([type]) => type))
|
||||||
|
const found: string[] = []
|
||||||
|
for (const type of held.keys()) {
|
||||||
|
if (!spelledNames.has(type) && !carried.includes(type) && !grammarOwn.includes(type)) found.push(`${type}: the schema holds the type, the tables spell none of it and the carried list does not name it`)
|
||||||
|
}
|
||||||
|
for (const type of carried) {
|
||||||
|
if (!held.has(type)) found.push(`${type}: the carried list names the type, the schema holds no definition of it`)
|
||||||
|
if (spelledNames.has(type)) found.push(`${type}: the carried list names the type, and the tables spell it`)
|
||||||
|
}
|
||||||
|
assert.deepEqual(found, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
function spelled(): Spelled[] {
|
||||||
|
return [
|
||||||
|
...Object.entries(blockDirectives).map(([type, entry]) => spelledType(type, entry.attributes, blockArgument(type))),
|
||||||
|
...Object.entries(inlineDirectives).map(([type, entry]) => spelledType(type, entry.attributes)),
|
||||||
|
...Object.entries(markAttributes).map(([type, attributes]) => spelledType(type, attributes)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function spelledType(type: string, attributes: AttributeVocabulary, argument?: string): Spelled {
|
||||||
|
const kinds = new Map(Object.entries(attributes))
|
||||||
|
if (argument !== undefined) kinds.set(argument, 'string')
|
||||||
|
return [type, kinds]
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaTypes(): Map<string, Held> {
|
||||||
|
const held = new Map<string, Held>()
|
||||||
|
for (const file of schemaFiles) {
|
||||||
|
const definitions = schemaObject(schemaObject(JSON.parse(readFileSync(join(root, file), 'utf8')), file)['definitions'], `${file} definitions`)
|
||||||
|
for (const [name, definition] of Object.entries(definitions)) {
|
||||||
|
const where = `${file} ${name}`
|
||||||
|
for (const properties of alternatives(schemaObject(definition, where), definitions, where)) {
|
||||||
|
const attributes = (properties.get('attrs') ?? []).flatMap((attrs) => alternatives(attrs, definitions, `${where} attrs`)).flatMap((alternative) => [...alternative])
|
||||||
|
for (const type of (properties.get('type') ?? []).flatMap((schema) => enumStrings(schema, `${where} type`))) {
|
||||||
|
const kinds = held.get(type) ?? new Map<string, Set<AttributeKind>>()
|
||||||
|
held.set(type, kinds)
|
||||||
|
for (const [attribute, schemas] of attributes) {
|
||||||
|
const attributeKinds = kinds.get(attribute) ?? new Set<AttributeKind>()
|
||||||
|
kinds.set(attribute, attributeKinds)
|
||||||
|
for (const schema of schemas) for (const kind of propertyKinds(schema, `${where} ${type}.${attribute}`)) attributeKinds.add(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return held
|
||||||
|
}
|
||||||
|
|
||||||
|
function alternatives(schema: SchemaObject, definitions: SchemaObject, where: string): Properties[] {
|
||||||
|
const own = Object.entries(schemaObject(readSchema(schema, where)['properties'] ?? {}, `${where} properties`))
|
||||||
|
let found: Properties[] = [new Map(own.map(([name, property]): [string, SchemaObject[]] => [name, [readSchema(schemaObject(property, `${where} ${name}`), `${where} ${name}`)]]))]
|
||||||
|
if (schema['$ref'] !== undefined) found = intersect(found, alternatives(referenced(schema['$ref'], definitions, where), definitions, `${where} ${String(schema['$ref'])}`))
|
||||||
|
for (const branch of branches(schema['allOf'], `${where} allOf`)) found = intersect(found, alternatives(branch, definitions, `${where} allOf`))
|
||||||
|
if (schema['anyOf'] !== undefined) found = intersect(found, branches(schema['anyOf'], `${where} anyOf`).flatMap((branch) => alternatives(branch, definitions, `${where} anyOf`)))
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSchema(schema: SchemaObject, where: string): SchemaObject {
|
||||||
|
const unread = Object.keys(schema).find((keyword) => !readKeywords.includes(keyword))
|
||||||
|
if (unread !== undefined) return assert.fail(`${where}: the schema holds the keyword ${unread}, which the gate does not read`)
|
||||||
|
const extra = schema['additionalProperties']
|
||||||
|
return extra === undefined || typeof extra === 'boolean' ? schema : assert.fail(`${where}: additionalProperties holds a schema, which the gate does not read`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersect(left: readonly Properties[], right: readonly Properties[]): Properties[] {
|
||||||
|
return left.flatMap((own) =>
|
||||||
|
right.map((other) => {
|
||||||
|
const merged = new Map(own)
|
||||||
|
for (const [name, schemas] of other) merged.set(name, [...(merged.get(name) ?? []), ...schemas])
|
||||||
|
return merged
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenced(reference: unknown, definitions: SchemaObject, where: string): SchemaObject {
|
||||||
|
const name = typeof reference === 'string' && reference.startsWith(definitionReference) ? reference.slice(definitionReference.length) : undefined
|
||||||
|
return schemaObject(name === undefined ? undefined : definitions[name], `${where}: the reference ${String(reference)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function branches(value: unknown, where: string): SchemaObject[] {
|
||||||
|
if (value === undefined) return []
|
||||||
|
return Array.isArray(value) ? value.map((branch, index) => schemaObject(branch, `${where} ${index}`)) : assert.fail(`${where} is no array of schemas`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumStrings(schema: SchemaObject, where: string): string[] {
|
||||||
|
const values = schema['enum']
|
||||||
|
return Array.isArray(values) ? values.filter((value: unknown): value is string => typeof value === 'string') : assert.fail(`${where}: the type property holds no enum naming the type`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function propertyKinds(property: SchemaObject, where: string): AttributeKind[] {
|
||||||
|
const type = property['type']
|
||||||
|
if (type === 'boolean') return ['boolean']
|
||||||
|
if (type === 'integer' || type === 'number') return ['number']
|
||||||
|
if (type === 'string') return ['string']
|
||||||
|
if (type === 'array' || type === 'object') return ['json']
|
||||||
|
if (type !== undefined) return assert.fail(`${where}: the schema types it ${JSON.stringify(type)}, which reads as no attribute kind`)
|
||||||
|
const combinator = ['$ref', 'allOf', 'anyOf'].find((keyword) => property[keyword] !== undefined)
|
||||||
|
if (combinator !== undefined) return assert.fail(`${where}: the schema holds the keyword ${combinator}, which the gate reads as no attribute kind`)
|
||||||
|
const values = property['enum']
|
||||||
|
return Array.isArray(values) ? values.map(valueKind) : ['json']
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueKind(value: unknown): AttributeKind {
|
||||||
|
if (typeof value === 'boolean') return 'boolean'
|
||||||
|
if (typeof value === 'number') return 'number'
|
||||||
|
if (typeof value === 'string') return 'string'
|
||||||
|
return 'json'
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaObject(value: unknown, where: string): SchemaObject {
|
||||||
|
return isSchemaObject(value) ? value : assert.fail(`${where} is no JSON Schema object`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSchemaObject(value: unknown): value is SchemaObject {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
+21
-9
@@ -52,8 +52,8 @@ export function attributeNestingMessage(key: string, type: string, levels: numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
|
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
|
||||||
if ((node.marks ?? []).length > 0 || node.text !== undefined) return false
|
if (nodeMarks(node).length > 0 || node.text !== undefined) return false
|
||||||
return holdsOnly(node.attrs ?? {}, attributes)
|
return holdsOnly(nodeAttrs(node), attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
|
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
|
||||||
@@ -72,6 +72,18 @@ export function isAdfMark(value: unknown): value is AdfMark {
|
|||||||
return !('attrs' in value) || isAttributes(value['attrs'])
|
return !('attrs' in value) || isAttributes(value['attrs'])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function nodeAttrs(node: { attrs?: AdfAttributes }): Readonly<AdfAttributes> {
|
||||||
|
return node.attrs ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeContent(node: { content?: AdfNode[] }): readonly AdfNode[] {
|
||||||
|
return node.content ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] {
|
||||||
|
return node.marks ?? []
|
||||||
|
}
|
||||||
|
|
||||||
function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
|
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) {
|
||||||
@@ -95,23 +107,23 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
|
|||||||
while (pending.length > 0) {
|
while (pending.length > 0) {
|
||||||
const node = pending.pop()
|
const node = pending.pop()
|
||||||
if (node === undefined) continue
|
if (node === undefined) continue
|
||||||
const fault = attributesFault(node.attrs, node.type) ?? marksFault(node.marks)
|
const fault = attributesFault(nodeAttrs(node), node.type) ?? marksFault(nodeMarks(node))
|
||||||
if (fault !== undefined) return fault
|
if (fault !== undefined) return fault
|
||||||
pending.push(...(node.content ?? []))
|
pending.push(...nodeContent(node))
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function marksFault(marks: readonly AdfMark[] | undefined): ConvertFault | undefined {
|
function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined {
|
||||||
for (const mark of marks ?? []) {
|
for (const mark of marks) {
|
||||||
const fault = attributesFault(mark.attrs, mark.type, markAttributeNesting)
|
const fault = attributesFault(nodeAttrs(mark), mark.type, markAttributeNesting)
|
||||||
if (fault !== undefined) return fault
|
if (fault !== undefined) return fault
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function attributesFault(attrs: AdfAttributes | undefined, type: string, levels: number = largestNesting): ConvertFault | undefined {
|
function attributesFault(attrs: AdfAttributes, type: string, levels: number = largestNesting): ConvertFault | undefined {
|
||||||
for (const [key, value] of Object.entries(attrs ?? {})) {
|
for (const [key, value] of Object.entries(attrs)) {
|
||||||
if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) }
|
if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) }
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { AdfNode } from './document.ts'
|
||||||
|
import type { JsonValue } from '../json-value.ts'
|
||||||
|
import { toEditorNormal } from './editor-normal.ts'
|
||||||
|
|
||||||
|
test('merges adjacent text nodes carrying identical marks, at every level', () => {
|
||||||
|
const content: AdfNode[] = [
|
||||||
|
{ text: 'a', type: 'text' },
|
||||||
|
{ text: 'b', type: 'text' },
|
||||||
|
{ marks: [{ type: 'strong' }], text: 'c', type: 'text' },
|
||||||
|
{ marks: [{ attrs: {}, type: 'strong' }], text: 'd', type: 'text' },
|
||||||
|
{ type: 'hardBreak' },
|
||||||
|
{ text: 'e', type: 'text' },
|
||||||
|
{ attrs: { localId: '01a0a06b-5281-7f27-9022-8d3a74b0ab0d' }, text: 'f', type: 'text' },
|
||||||
|
{ text: 'g', type: 'text' },
|
||||||
|
{ attrs: {}, text: 'h', type: 'text' },
|
||||||
|
]
|
||||||
|
assert.deepEqual(toEditorNormal({ content: [{ attrs: { panelType: 'info' }, content: [{ content, type: 'paragraph' }], type: 'panel' }], type: 'doc', version: 1 }), {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
attrs: { panelType: 'info' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
content: [
|
||||||
|
{ text: 'ab', type: 'text' },
|
||||||
|
{ marks: [{ type: 'strong' }], text: 'cd', type: 'text' },
|
||||||
|
{ type: 'hardBreak' },
|
||||||
|
{ text: 'e', type: 'text' },
|
||||||
|
{ attrs: { localId: '01a0a06b-5281-7f27-9022-8d3a74b0ab0d' }, text: 'f', type: 'text' },
|
||||||
|
{ text: 'gh', type: 'text' },
|
||||||
|
],
|
||||||
|
type: 'paragraph',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: 'panel',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: 'doc',
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads negative zero as zero, as JSON does', () => {
|
||||||
|
const marks = [{ attrs: { size: -0 }, type: 'border' }]
|
||||||
|
assert.deepEqual(
|
||||||
|
toEditorNormal({ content: [{ attrs: { a: -0, b: [{ c: -0 }, null, 'd'] }, content: [{ marks, text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: -0 }),
|
||||||
|
{ content: [{ attrs: { a: 0, b: [{ c: 0 }, null, 'd'] }, content: [{ marks: [{ attrs: { size: 0 }, type: 'border' }], text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 0 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads an empty attrs object, marks array or content array as the absent key', () => {
|
||||||
|
const paragraph: AdfNode = { attrs: {}, content: [{ attrs: {}, marks: [], text: 'a', type: 'text' }, { marks: [{ attrs: {}, type: 'em' }], text: 'b', type: 'text' }], marks: [], type: 'paragraph' }
|
||||||
|
assert.deepEqual(toEditorNormal({ content: [paragraph, { content: [], type: 'rule' }], type: 'doc', version: 1 }), {
|
||||||
|
content: [{ content: [{ text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: 'b', type: 'text' }], type: 'paragraph' }, { type: 'rule' }],
|
||||||
|
type: 'doc',
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
assert.deepEqual(toEditorNormal({ content: [], type: 'doc', version: 1 }), { type: 'doc', version: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normalizes blocks and mark attributes nesting far past the levels a recursive walk survives', () => {
|
||||||
|
const levels = 100000
|
||||||
|
let node: AdfNode = { content: [], type: 'paragraph' }
|
||||||
|
for (let level = 0; level < levels; level += 1) node = { content: [node], type: 'blockquote' }
|
||||||
|
let normal = toEditorNormal({ content: [node], type: 'doc', version: 1 }).content?.[0]
|
||||||
|
let depth = 0
|
||||||
|
for (; normal?.content !== undefined; depth += 1) normal = normal.content[0]
|
||||||
|
assert.equal(depth, levels)
|
||||||
|
assert.deepEqual(normal, { type: 'paragraph' })
|
||||||
|
let deep: JsonValue = 1
|
||||||
|
for (let level = 0; level < 2 * levels; level += 1) deep = [deep]
|
||||||
|
const marks = [{ attrs: { deep }, type: 'textColor' }]
|
||||||
|
const merged = toEditorNormal({ content: [{ content: [{ marks, text: 'a', type: 'text' }, { marks, text: 'b', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 })
|
||||||
|
assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab'])
|
||||||
|
})
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
import type { AdfMark, AdfNode } from './document.ts'
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts'
|
||||||
|
import type { JsonValue } from '../json-value.ts'
|
||||||
|
import { nodeAttrs, nodeContent, nodeMarks } from './document.ts'
|
||||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
|
|
||||||
|
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
|
||||||
|
|
||||||
|
type NodeHolder = { content?: AdfNode[] }
|
||||||
|
|
||||||
export function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
|
export function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
|
||||||
return markKey(candidate) === markKey(mark)
|
return markKey(candidate) === markKey(mark)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AGENTS.md §2: adjacent text nodes carrying identical marks are one node.
|
|
||||||
export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
|
export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
|
||||||
const merged: AdfNode[] = []
|
const merged: AdfNode[] = []
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const previous = merged[merged.length - 1]
|
const previous = merged[merged.length - 1]
|
||||||
if (previous !== undefined && previous.type === 'text' && node.type === 'text' && sameMarks(previous, node)) {
|
if (previous !== undefined && mergesText(previous) && mergesText(node) && sameMarks(previous, node)) {
|
||||||
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
|
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -19,8 +24,61 @@ export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toEditorNormal(document: AdfDocument): AdfDocument {
|
||||||
|
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
|
||||||
|
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
|
||||||
|
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
|
||||||
|
const content = mergeAdjacentText(nodeContent(entry.source))
|
||||||
|
if (content.length === 0) continue
|
||||||
|
entry.holder.content = content.map((source) => {
|
||||||
|
const holder = normalNode(source)
|
||||||
|
pending.push({ holder, source })
|
||||||
|
return holder
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return normal
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalNode(node: AdfNode): AdfNode {
|
||||||
|
const normal: AdfNode = { type: node.type }
|
||||||
|
const attrs = normalAttributes(nodeAttrs(node))
|
||||||
|
if (attrs !== undefined) normal.attrs = attrs
|
||||||
|
const marks = nodeMarks(node).map(normalMark)
|
||||||
|
if (marks.length > 0) normal.marks = marks
|
||||||
|
if (node.text !== undefined) normal.text = node.text
|
||||||
|
return normal
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalMark(mark: AdfMark): AdfMark {
|
||||||
|
const attrs = normalAttributes(nodeAttrs(mark))
|
||||||
|
return attrs === undefined ? { type: mark.type } : { attrs, type: mark.type }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalAttributes(attrs: AdfAttributes): AdfAttributes | undefined {
|
||||||
|
if (Object.keys(attrs).length === 0) return undefined
|
||||||
|
const normal = { ...attrs }
|
||||||
|
const pending: JsonContainer[] = [normal]
|
||||||
|
for (let held = pending.pop(); held !== undefined; held = pending.pop()) {
|
||||||
|
if (Array.isArray(held)) for (const [index, value] of held.entries()) held[index] = normalValue(value, pending)
|
||||||
|
else for (const [key, value] of Object.entries(held)) held[key] = normalValue(value, pending)
|
||||||
|
}
|
||||||
|
return normal
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue {
|
||||||
|
if (Object.is(value, -0)) return 0
|
||||||
|
if (value === null || typeof value !== 'object') return value
|
||||||
|
const copy = Array.isArray(value) ? [...value] : { ...value }
|
||||||
|
pending.push(copy)
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergesText(node: AdfNode): boolean {
|
||||||
|
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
function sameMarks(previous: AdfNode, node: AdfNode): boolean {
|
function sameMarks(previous: AdfNode, node: AdfNode): boolean {
|
||||||
return marksKey(previous.marks ?? []) === marksKey(node.marks ?? [])
|
return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))
|
||||||
}
|
}
|
||||||
|
|
||||||
function marksKey(marks: readonly AdfMark[]): string {
|
function marksKey(marks: readonly AdfMark[]): string {
|
||||||
@@ -28,5 +86,5 @@ function marksKey(marks: readonly AdfMark[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markKey(mark: AdfMark): string {
|
function markKey(mark: AdfMark): string {
|
||||||
return `${mark.type} ${serializeCanonicalJson(mark.attrs ?? {}, 'compact')}`
|
return `${mark.type} ${serializeCanonicalJson(nodeAttrs(mark), 'compact')}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { serializeCanonicalJson } from './canonical-json.ts'
|
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||||
|
|
||||||
test('sorts object keys recursively', () => {
|
test('sorts object keys recursively', () => {
|
||||||
@@ -39,3 +40,15 @@ test('leaves non-ASCII raw', () => {
|
|||||||
test('spells scalars in canonical JSON', () => {
|
test('spells scalars in canonical JSON', () => {
|
||||||
assert.equal(serializeCanonicalJson([null, true, false, 0, -1.5, 'a"b'], 'compact'), '[null,true,false,0,-1.5,"a\\"b"]')
|
assert.equal(serializeCanonicalJson([null, true, false, 0, -1.5, 'a"b'], 'compact'), '[null,true,false,0,-1.5,"a\\"b"]')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('spells a value nesting far past the levels a recursive walk survives', () => {
|
||||||
|
const levels = 200000
|
||||||
|
let array: JsonValue = 1
|
||||||
|
let object: JsonValue = 1
|
||||||
|
for (let level = 0; level < levels; level += 1) {
|
||||||
|
array = [array]
|
||||||
|
object = { a: object }
|
||||||
|
}
|
||||||
|
assert.equal(serializeCanonicalJson(array, 'compact'), `${'['.repeat(levels)}1${']'.repeat(levels)}`)
|
||||||
|
assert.equal(serializeCanonicalJson(object, 'compact'), `${'{"a":'.repeat(levels)}1${'}'.repeat(levels)}`)
|
||||||
|
})
|
||||||
|
|||||||
+33
-19
@@ -2,28 +2,42 @@ import type { JsonValue } from './json-value.ts'
|
|||||||
|
|
||||||
export type JsonSpelling = 'compact' | 'two-space'
|
export type JsonSpelling = 'compact' | 'two-space'
|
||||||
|
|
||||||
|
type Member = { label: string; value: JsonValue }
|
||||||
|
|
||||||
|
type Pending = string | { depth: number; value: JsonValue }
|
||||||
|
|
||||||
export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string {
|
export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string {
|
||||||
return serialize(value, spelling === 'compact' ? '' : ' ', 0)
|
const indent = spelling === 'compact' ? '' : ' '
|
||||||
|
const text: string[] = []
|
||||||
|
const pending: Pending[] = [{ depth: 0, value }]
|
||||||
|
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
|
||||||
|
if (typeof next === 'string') {
|
||||||
|
text.push(next)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const { depth, value: held } = next
|
||||||
|
if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth)
|
||||||
|
else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth)
|
||||||
|
else text.push(JSON.stringify(held))
|
||||||
|
}
|
||||||
|
return text.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function serialize(value: JsonValue, indent: string, depth: number): string {
|
function objectMembers(value: { [key: string]: JsonValue }, indent: string): Member[] {
|
||||||
if (Array.isArray(value)) {
|
const separator = indent === '' ? ':' : ': '
|
||||||
if (value.length === 0) return '[]'
|
return Object.keys(value)
|
||||||
const items = value.map((item) => serialize(item, indent, depth + 1))
|
.sort()
|
||||||
return `[${join(items, indent, depth)}]`
|
.map((key) => ({ label: `${JSON.stringify(key)}${separator}`, value: value[key] ?? null }))
|
||||||
}
|
|
||||||
if (value !== null && typeof value === 'object') {
|
|
||||||
const keys = Object.keys(value).sort()
|
|
||||||
if (keys.length === 0) return '{}'
|
|
||||||
const separator = indent === '' ? ':' : ': '
|
|
||||||
const entries = keys.map((key) => `${JSON.stringify(key)}${separator}${serialize(value[key] ?? null, indent, depth + 1)}`)
|
|
||||||
return `{${join(entries, indent, depth)}}`
|
|
||||||
}
|
|
||||||
return JSON.stringify(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function join(parts: readonly string[], indent: string, depth: number): string {
|
function schedule(pending: Pending[], open: string, members: readonly Member[], close: string, indent: string, depth: number): void {
|
||||||
if (indent === '') return parts.join(',')
|
if (members.length === 0) {
|
||||||
const inner = `\n${indent.repeat(depth + 1)}`
|
pending.push(`${open}${close}`)
|
||||||
return `${inner}${parts.join(`,${inner}`)}\n${indent.repeat(depth)}`
|
return
|
||||||
|
}
|
||||||
|
const inner = indent === '' ? '' : `\n${indent.repeat(depth + 1)}`
|
||||||
|
const scheduled: Pending[] = []
|
||||||
|
for (const [index, member] of members.entries()) scheduled.push(`${index === 0 ? open : ','}${inner}${member.label}`, { depth: depth + 1, value: member.value })
|
||||||
|
scheduled.push(indent === '' ? close : `\n${indent.repeat(depth)}${close}`)
|
||||||
|
for (const item of scheduled.reverse()) pending.push(item)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+7
-17
@@ -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'
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import { isAdfDocument } from './adf/document.ts'
|
|||||||
import { isJsonValue } from './json-value.ts'
|
import { isJsonValue } from './json-value.ts'
|
||||||
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||||
|
import { toEditorNormal } from './adf/editor-normal.ts'
|
||||||
|
|
||||||
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
|
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
|
||||||
const errorsRoot = join(corpusRoot, 'errors')
|
const errorsRoot = join(corpusRoot, 'errors')
|
||||||
@@ -53,12 +54,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', () => {
|
||||||
@@ -94,7 +96,7 @@ for (const directory of roundTripDirectories) {
|
|||||||
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
||||||
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
|
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
|
||||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
||||||
assert.deepEqual(result.value, expected)
|
assert.deepEqual(toEditorNormal(result.value), expected)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,18 +107,6 @@ function roundTripFixtures(): { name: string; path: string }[] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
test('no two round-trip documents share one markdown spelling', () => {
|
|
||||||
const spellings = new Map<string, string>()
|
|
||||||
for (const fixture of roundTripFixtures()) {
|
|
||||||
const parsed: unknown = JSON.parse(readFileSync(fixture.path, 'utf8'))
|
|
||||||
assert.ok(isAdfDocument(parsed), `${fixture.name} is not an ADF document`)
|
|
||||||
const result = adfToMarkdown(parsed)
|
|
||||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
|
||||||
assert.equal(spellings.get(result.value), undefined, `${fixture.name} and ${spellings.get(result.value)} share one markdown spelling`)
|
|
||||||
spellings.set(result.value, fixture.name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('no round-trip fixture repeats the document another holds', () => {
|
test('no round-trip fixture repeats the document another holds', () => {
|
||||||
const documents = new Map<string, string>()
|
const documents = new Map<string, string>()
|
||||||
for (const fixture of roundTripFixtures()) {
|
for (const fixture of roundTripFixtures()) {
|
||||||
@@ -183,12 +173,12 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
|
|||||||
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
||||||
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
|
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
|
||||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
||||||
assert.deepEqual(result.value, expected)
|
assert.deepEqual(toEditorNormal(result.value), expected)
|
||||||
const emitted = adfToMarkdown(result.value)
|
const emitted = adfToMarkdown(result.value)
|
||||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
||||||
const again = markdownToAdf(emitted.value)
|
const again = markdownToAdf(emitted.value)
|
||||||
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
|
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
|
||||||
assert.deepEqual(again.value, expected)
|
assert.deepEqual(toEditorNormal(again.value), expected)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { AdfMark } from '../adf/document.ts'
|
import type { AdfMark } from '../adf/document.ts'
|
||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import { isAdfMark } from '../adf/document.ts'
|
import { isAdfMark, nodeAttrs } from '../adf/document.ts'
|
||||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
|
|
||||||
export const marksAttribute = 'marks'
|
export const marksAttribute = 'marks'
|
||||||
|
|
||||||
export function markValues(marks: readonly AdfMark[]): JsonValue {
|
export function markValues(marks: readonly AdfMark[]): JsonValue {
|
||||||
return marks.map((mark) => {
|
return marks.map((mark) => {
|
||||||
const attrs = mark.attrs ?? {}
|
const attrs = nodeAttrs(mark)
|
||||||
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
|
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const htmlBlockConditions: HtmlBlockCondition[] = [
|
|||||||
]
|
]
|
||||||
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
||||||
const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/
|
const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/
|
||||||
|
const blankLine = /^[ \t]*$/
|
||||||
const codeFenceOpener = /^(`{3,}|~{3,})/
|
const codeFenceOpener = /^(`{3,}|~{3,})/
|
||||||
const directiveClaim = /^:{2,}(?:[A-Za-z0-9]|[ \t]*$)/
|
const directiveClaim = /^:{2,}(?:[A-Za-z0-9]|[ \t]*$)/
|
||||||
const pipeClaim = /^\|/
|
const pipeClaim = /^\|/
|
||||||
@@ -167,6 +168,10 @@ export function isAutolink(text: string): boolean {
|
|||||||
return autolink.test(text)
|
return autolink.test(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isBlankLine(line: string): boolean {
|
||||||
|
return blankLine.test(line)
|
||||||
|
}
|
||||||
|
|
||||||
export function isThematicBreak(line: string): boolean {
|
export function isThematicBreak(line: string): boolean {
|
||||||
return thematicBreak.test(line)
|
return thematicBreak.test(line)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ export function attributeValue(text: string, kind: AttributeKind): AttributeRead
|
|||||||
return overNested(parsed) ? { refusal: 'nesting' } : { value: { kind, value: parsed } }
|
return overNested(parsed) ? { refusal: 'nesting' } : { value: { kind, value: parsed } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function holdsInlineDirectiveOpener(text: string): boolean {
|
||||||
|
for (let index = text.indexOf(':'); index !== -1; index = text.indexOf(':', index + 1)) if (opensInlineDirective(text, index)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export function isBareToken(text: string): boolean {
|
export function isBareToken(text: string): boolean {
|
||||||
return bareToken.test(text)
|
return bareToken.test(text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { JsonValue } from '../../json-value.ts'
|
|||||||
import type { Result } from '../../result.ts'
|
import type { Result } from '../../result.ts'
|
||||||
import { adfToMarkdown, markdownToAdf } from '../../index.ts'
|
import { adfToMarkdown, markdownToAdf } from '../../index.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
|
import { toEditorNormal } from '../../adf/editor-normal.ts'
|
||||||
|
|
||||||
function document(...content: AdfNode[]): AdfDocument {
|
function document(...content: AdfNode[]): AdfDocument {
|
||||||
return { content, type: 'doc', version: 1 }
|
return { content, type: 'doc', version: 1 }
|
||||||
@@ -353,7 +354,9 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
|
|||||||
const roundTrips = (node: AdfNode): void => {
|
const roundTrips = (node: AdfNode): void => {
|
||||||
const spelled = adfToMarkdown(document(node))
|
const spelled = adfToMarkdown(document(node))
|
||||||
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
|
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
|
||||||
assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(node) })
|
const read = markdownToAdf(spelled.value)
|
||||||
|
assert.ok(read.ok, read.ok ? '' : read.error.message)
|
||||||
|
assert.deepEqual(toEditorNormal(read.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({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3))
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||||
import type { BlockDirective } from '../../adf/block-directives.ts'
|
import type { BlockDirective } from '../../adf/block-directives.ts'
|
||||||
import { adfDocumentFault, carriesOnly } from '../../adf/document.ts'
|
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } 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, faulted, 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, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts'
|
||||||
import { languageSlot } from '../code-language.ts'
|
import { languageSlot } from '../code-language.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { listBreakSpelling } from '../list-break.ts'
|
import { listBreakSpelling } from '../list-break.ts'
|
||||||
@@ -26,7 +26,7 @@ export function adfToMarkdown(document: AdfDocument): Result<string> {
|
|||||||
const fault = adfDocumentFault(document)
|
const fault = adfDocumentFault(document)
|
||||||
if (fault !== undefined) return faulted(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(nodeContent(document), 'document', [], 0)
|
||||||
if (!blocks.ok) return blocks
|
if (!blocks.ok) return blocks
|
||||||
return success(blocks.value.text === '' ? '' : `${blocks.value.text}\n`)
|
return success(blocks.value.text === '' ? '' : `${blocks.value.text}\n`)
|
||||||
}
|
}
|
||||||
@@ -63,8 +63,8 @@ function separationBetween(previous: PlacedBlock, next: PlacedBlock, container:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function interruptsParagraph(node: AdfNode): boolean {
|
function interruptsParagraph(node: AdfNode): boolean {
|
||||||
const items = node.content ?? []
|
const items = nodeContent(node)
|
||||||
const empty = (items[0]?.content ?? []).length === 0
|
const empty = items[0] === undefined || nodeContent(items[0]).length === 0
|
||||||
if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty)
|
if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty)
|
||||||
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty)
|
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty)
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ function commonMarkText(text: string): EmittedBlock {
|
|||||||
|
|
||||||
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||||
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
|
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
|
||||||
const content = node.content ?? []
|
const content = nodeContent(node)
|
||||||
if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
|
if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
|
||||||
if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth)
|
if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth)
|
||||||
const header = spellDirectiveHeader(node, directive)
|
const header = spellDirectiveHeader(node, directive)
|
||||||
@@ -134,7 +134,7 @@ function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Re
|
|||||||
|
|
||||||
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
|
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
|
||||||
if (!carriesOnly(node, [])) return undefined
|
if (!carriesOnly(node, [])) return undefined
|
||||||
const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1)
|
const inner = emitBlocks(nodeContent(node), 'document', path, depth + 1)
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
const text = inner.value.text
|
const text = inner.value.text
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -145,7 +145,7 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R
|
|||||||
|
|
||||||
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||||
if (!carriesOnly(node, ['language'])) return undefined
|
if (!carriesOnly(node, ['language'])) return undefined
|
||||||
const slot = languageSlot(node.attrs?.['language'])
|
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||||
if (slot.kind === 'attribute') return undefined
|
if (slot.kind === 'attribute') return undefined
|
||||||
const text = codeBlockText(node, path)
|
const text = codeBlockText(node, path)
|
||||||
if (!text.ok) return text
|
if (!text.ok) return text
|
||||||
@@ -153,7 +153,7 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||||
const slot = languageSlot(node.attrs?.['language'])
|
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||||
const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language'])
|
const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language'])
|
||||||
if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||||
const text = codeBlockText(node, path)
|
const text = codeBlockText(node, path)
|
||||||
@@ -164,15 +164,15 @@ function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: Conve
|
|||||||
|
|
||||||
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||||
let text = ''
|
let text = ''
|
||||||
for (const [index, child] of (node.content ?? []).entries()) {
|
for (const [index, child] of nodeContent(node).entries()) {
|
||||||
const childPath = [...path, 'content', index]
|
const childPath = [...path, 'content', index]
|
||||||
if (
|
if (
|
||||||
child.type !== 'text' ||
|
child.type !== 'text' ||
|
||||||
typeof child.text !== 'string' ||
|
typeof child.text !== 'string' ||
|
||||||
child.text === '' ||
|
child.text === '' ||
|
||||||
(child.content ?? []).length > 0 ||
|
nodeContent(child).length > 0 ||
|
||||||
(child.marks ?? []).length > 0 ||
|
nodeMarks(child).length > 0 ||
|
||||||
Object.keys(child.attrs ?? {}).length > 0
|
Object.keys(nodeAttrs(child)).length > 0
|
||||||
) {
|
) {
|
||||||
return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath)
|
return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath)
|
||||||
}
|
}
|
||||||
@@ -185,10 +185,10 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
|||||||
|
|
||||||
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||||
if (!carriesOnly(node, ['level'])) return undefined
|
if (!carriesOnly(node, ['level'])) return undefined
|
||||||
const level = node.attrs?.['level']
|
const level = nodeAttrs(node)['level']
|
||||||
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
|
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
|
||||||
const hashes = '#'.repeat(level)
|
const hashes = '#'.repeat(level)
|
||||||
const content = node.content ?? []
|
const content = nodeContent(node)
|
||||||
if (content.length === 0) return success(commonMarkText(hashes))
|
if (content.length === 0) return success(commonMarkText(hashes))
|
||||||
const line = emitInlineLine(content, 'heading', path)
|
const line = emitInlineLine(content, 'heading', path)
|
||||||
if (!line.ok) return line
|
if (!line.ok) return line
|
||||||
@@ -198,7 +198,7 @@ function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock
|
|||||||
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
|
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
|
||||||
const ordered = node.type === 'orderedList'
|
const ordered = node.type === 'orderedList'
|
||||||
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
|
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
|
||||||
const items = node.content ?? []
|
const items = nodeContent(node)
|
||||||
const start = listStart(node, items.length)
|
const start = listStart(node, items.length)
|
||||||
if (start === undefined || items.length === 0) return undefined
|
if (start === undefined || items.length === 0) return undefined
|
||||||
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
|
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
|
||||||
@@ -216,23 +216,25 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<
|
|||||||
|
|
||||||
function listStart(node: AdfNode, items: number): number | undefined {
|
function listStart(node: AdfNode, items: number): number | undefined {
|
||||||
if (node.type !== 'orderedList') return 0
|
if (node.type !== 'orderedList') return 0
|
||||||
const start = node.attrs?.['order']
|
const start = nodeAttrs(node)['order']
|
||||||
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined
|
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined
|
||||||
return start + items - 1 > largestListMarker ? undefined : start
|
return start + items - 1 > largestListMarker ? undefined : start
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> | undefined {
|
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> | undefined {
|
||||||
const inner = emitBlocks(item.content ?? [], 'list-item', path, depth + 1)
|
const inner = emitBlocks(nodeContent(item), 'list-item', path, depth + 1)
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() })
|
if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() })
|
||||||
|
const body = inner.value.text.split('\n')
|
||||||
|
if (body.some((line) => line !== '' && isBlankLine(line))) return undefined
|
||||||
const indent = ' '.repeat(marker.length)
|
const indent = ' '.repeat(marker.length)
|
||||||
const lines = inner.value.text.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
|
const lines = body.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
|
||||||
if (isThematicBreak(lines[0] ?? '')) return undefined
|
if (isThematicBreak(lines[0] ?? '')) return undefined
|
||||||
return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') })
|
return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') })
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||||
const content = node.content ?? []
|
const content = nodeContent(node)
|
||||||
if (content.length === 0 || !carriesOnly(node, [])) return undefined
|
if (content.length === 0 || !carriesOnly(node, [])) return undefined
|
||||||
const line = emitInlineLine(content, 'paragraph', path)
|
const line = emitInlineLine(content, 'paragraph', path)
|
||||||
if (!line.ok) return line
|
if (!line.ok) return line
|
||||||
@@ -240,6 +242,6 @@ function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emitRule(node: AdfNode): Result<EmittedBlock> | undefined {
|
function emitRule(node: AdfNode): Result<EmittedBlock> | undefined {
|
||||||
if (!carriesOnly(node, []) || (node.content ?? []).length > 0) return undefined
|
if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined
|
||||||
return success(commonMarkText('---'))
|
return success(commonMarkText('---'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BlockDirective } from '../../adf/block-directives.ts'
|
|||||||
import { blockArgument } from '../block-directive-arguments.ts'
|
import { blockArgument } from '../block-directive-arguments.ts'
|
||||||
import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
|
import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
|
||||||
import { markValues, marksAttribute } from '../block-directive-marks.ts'
|
import { markValues, marksAttribute } from '../block-directive-marks.ts'
|
||||||
|
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||||
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
||||||
|
|
||||||
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined {
|
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined {
|
||||||
@@ -10,17 +11,17 @@ export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, s
|
|||||||
const argument = spellArgument(node, argumentAttribute)
|
const argument = spellArgument(node, argumentAttribute)
|
||||||
if (argument === undefined) return undefined
|
if (argument === undefined) return undefined
|
||||||
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
|
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
|
||||||
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, spelled)
|
const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, spelled)
|
||||||
if (pairs === undefined) return undefined
|
if (pairs === undefined) return undefined
|
||||||
const spelledPairs = spellVocabulary(pairs)
|
const spelledPairs = spellVocabulary(pairs)
|
||||||
const marks = node.marks ?? []
|
const marks = nodeMarks(node)
|
||||||
if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))])
|
if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))])
|
||||||
const attributes = spellAttributes(spelledPairs)
|
const attributes = spellAttributes(spelledPairs)
|
||||||
return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`
|
return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined {
|
function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined {
|
||||||
const value = argumentAttribute === undefined ? undefined : node.attrs?.[argumentAttribute]
|
const value = argumentAttribute === undefined ? undefined : nodeAttrs(node)[argumentAttribute]
|
||||||
if (value === undefined) return ''
|
if (value === undefined) return ''
|
||||||
if (typeof value !== 'string' || !isBareToken(value)) return undefined
|
if (typeof value !== 'string' || !isBareToken(value)) return undefined
|
||||||
return ` ${value}`
|
return ` ${value}`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AdfNode } from '../../adf/document.ts'
|
import type { AdfNode } from '../../adf/document.ts'
|
||||||
import { carriesOnly } from '../../adf/document.ts'
|
import { carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||||
import type { ConvertErrorPath } from '../../result.ts'
|
import type { ConvertErrorPath } from '../../result.ts'
|
||||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||||
import { tryImageLine } from './inline-line.ts'
|
import { tryImageLine } from './inline-line.ts'
|
||||||
@@ -14,11 +14,11 @@ export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefi
|
|||||||
}
|
}
|
||||||
|
|
||||||
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
||||||
const content = node.content ?? []
|
const content = nodeContent(node)
|
||||||
const media = content[0]
|
const media = content[0]
|
||||||
if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle) return undefined
|
if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined
|
||||||
if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || (media.content ?? []).length > 0) return undefined
|
if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || nodeContent(media).length > 0) return undefined
|
||||||
const attrs = media.attrs ?? {}
|
const attrs = nodeAttrs(media)
|
||||||
const alt = attrs['alt']
|
const alt = attrs['alt']
|
||||||
const url = attrs['url']
|
const url = attrs['url']
|
||||||
if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined
|
if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { AdfNode } from '../../adf/document.ts'
|
import type { AdfNode } from '../../adf/document.ts'
|
||||||
import type { InlineDirective } from '../../adf/inline-directives.ts'
|
import type { InlineDirective } from '../../adf/inline-directives.ts'
|
||||||
|
import { nodeAttrs } from '../../adf/document.ts'
|
||||||
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
|
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
|
||||||
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
||||||
|
|
||||||
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined {
|
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined {
|
||||||
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute])
|
const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute])
|
||||||
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
|
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import type { InlineDirective } from '../../adf/inline-directives.ts'
|
import type { InlineDirective } from '../../adf/inline-directives.ts'
|
||||||
import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
|
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
|
||||||
import { carriedInline } from '../opaque-carry.ts'
|
import { carriedInline } from '../opaque-carry.ts'
|
||||||
import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts'
|
import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts'
|
||||||
|
import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts'
|
||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
import { holdsEntityReference } from '../entity-references.ts'
|
import { holdsEntityReference } from '../entity-references.ts'
|
||||||
|
import { holdsInlineDirectiveOpener, slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts'
|
||||||
import { inlineDirective } from '../../adf/inline-directives.ts'
|
import { inlineDirective } from '../../adf/inline-directives.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { longestBacktickRun } from '../backtick-runs.ts'
|
import { longestBacktickRun } from '../backtick-runs.ts'
|
||||||
import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||||
|
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
import { sameMark } from '../../adf/editor-normal.ts'
|
import { sameMark } from '../../adf/editor-normal.ts'
|
||||||
import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts'
|
|
||||||
import { spellDestination, spellTitle } from '../link-syntax.ts'
|
|
||||||
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
|
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
|
||||||
import { spellTextDirective } from '../text-directive.ts'
|
import { spellTextDirective } from '../text-directive.ts'
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain
|
|||||||
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
|
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
|
||||||
const emitted = emitLine(nodes, 'table-cell', path)
|
const emitted = emitLine(nodes, 'table-cell', path)
|
||||||
if (!emitted.ok) return undefined
|
if (!emitted.ok) return undefined
|
||||||
if (emitted.value.segments.some((segment) => segment.escaping === 'none' && segment.text.includes('|'))) return undefined
|
if (emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined
|
||||||
return emitted.value.line
|
return emitted.value.line
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ function syntax(text: string): InlineSegment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result<null> {
|
function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result<null> {
|
||||||
const holdsContent = (node.content ?? []).length > 0
|
const holdsContent = nodeContent(node).length > 0
|
||||||
if (holdsContent || node.text !== undefined) {
|
if (holdsContent || node.text !== undefined) {
|
||||||
const held = holdsContent ? 'content' : 'text'
|
const held = holdsContent ? 'content' : 'text'
|
||||||
return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text: this one holds ${held}`, path)
|
return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text: this one holds ${held}`, path)
|
||||||
@@ -156,7 +157,7 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
|
|||||||
for (const [offset, node] of nodes.entries()) {
|
for (const [offset, node] of nodes.entries()) {
|
||||||
const index = firstIndex + offset
|
const index = firstIndex + offset
|
||||||
// spec/flavour.md, Marks.
|
// spec/flavour.md, Marks.
|
||||||
const mark = carries(node, carried, index) ? undefined : (node.marks ?? [])[depth]
|
const mark = carries(node, carried, index) ? undefined : nodeMarks(node)[depth]
|
||||||
if (mark === undefined) {
|
if (mark === undefined) {
|
||||||
runs.push({ index, kind: 'plain', node })
|
runs.push({ index, kind: 'plain', node })
|
||||||
continue
|
continue
|
||||||
@@ -184,7 +185,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
|
|||||||
if (!carried.ok) return carried
|
if (!carried.ok) return carried
|
||||||
return success({ segments: [syntax(carried.value)] })
|
return success({ segments: [syntax(carried.value)] })
|
||||||
}
|
}
|
||||||
const types = (node.marks ?? []).map((mark) => mark.type)
|
const types = nodeMarks(node).map((mark) => mark.type)
|
||||||
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
|
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
|
||||||
const directive = inlineDirective(node.type)
|
const directive = inlineDirective(node.type)
|
||||||
if (directive === undefined) return emitText(node, context, index, path)
|
if (directive === undefined) return emitText(node, context, index, path)
|
||||||
@@ -206,7 +207,7 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n
|
|||||||
if (!empty.ok) return empty
|
if (!empty.ok) return empty
|
||||||
const attributes = spellInlineNodeAttributes(node, directive)
|
const attributes = spellInlineNodeAttributes(node, directive)
|
||||||
if (attributes === undefined) return success({ carry: { first: index, last: index } })
|
if (attributes === undefined) return success({ carry: { first: index, last: index } })
|
||||||
const slot = directive.textAttribute === undefined ? undefined : node.attrs?.[directive.textAttribute]
|
const slot = directive.textAttribute === undefined ? undefined : nodeAttrs(node)[directive.textAttribute]
|
||||||
if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] })
|
if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] })
|
||||||
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
|
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
|
||||||
const spans = slotLineEndingFault(node.type, slot)
|
const spans = slotLineEndingFault(node.type, slot)
|
||||||
@@ -217,9 +218,9 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||||
if (Object.keys(node.attrs ?? {}).length > 0) return success({ carry: { first: index, last: index } })
|
if (Object.keys(nodeAttrs(node)).length > 0) return success({ carry: { first: index, last: index } })
|
||||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
||||||
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
|
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
|
||||||
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
|
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
|
||||||
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
||||||
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
||||||
@@ -260,9 +261,9 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number
|
|||||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
||||||
let text = ''
|
let text = ''
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.type !== 'text' || (node.marks ?? []).length !== depth + 1) return success({ carry: range })
|
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1) return success({ carry: range })
|
||||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
||||||
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
|
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
|
||||||
text += node.text
|
text += node.text
|
||||||
}
|
}
|
||||||
if (/[\n\r]/.test(text)) return success({ carry: range })
|
if (/[\n\r]/.test(text)) return success({ carry: range })
|
||||||
@@ -278,19 +279,19 @@ function needsPadding(text: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result<Emission> {
|
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result<Emission> {
|
||||||
const href = mark.attrs?.['href']
|
const href = nodeAttrs(mark)['href']
|
||||||
const title = mark.attrs?.['title']
|
const title = nodeAttrs(mark)['title']
|
||||||
if (typeof href !== 'string') return success({ carry: range })
|
if (typeof href !== 'string') return success({ carry: range })
|
||||||
const node = nodes[0]
|
const node = nodes[0]
|
||||||
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1
|
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1
|
||||||
if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] })
|
const autolinkHolds = !context.bracketed || (!href.includes('`') && !holdsInlineDirectiveOpener(href) && escapeUnbalanced(href, '[', ']') === href)
|
||||||
const destination = spellDestination(href, path)
|
if (bare && autolinkHolds && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] })
|
||||||
if (!destination.ok) return destination
|
const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined, path)
|
||||||
const spelledTitle = typeof title === 'string' ? spellTitle(title, path) : success('')
|
if (!target.ok) return target
|
||||||
if (!spelledTitle.ok) return spelledTitle
|
|
||||||
const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true })
|
const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true })
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
if (inner.value.carry !== undefined) return inner
|
if (inner.value.carry !== undefined) return inner
|
||||||
return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${destination.value}${spelledTitle.value})`)] })
|
const spelledTarget: InlineSegment = context.bracketed ? { escaping: 'bracketed-link-target', text: escapeUnbalanced(target.value, '[', ']') } : syntax(target.value)
|
||||||
|
return success({ segments: [syntax('['), ...inner.value.segments, syntax(']('), spelledTarget, syntax(')')] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { readEntityReference } from '../entity-references.ts'
|
|||||||
|
|
||||||
export type EmphasisRole = 'close' | 'open'
|
export type EmphasisRole = 'close' | 'open'
|
||||||
|
|
||||||
export type InlineEscaping = 'backslash' | 'bracketed' | 'none'
|
export type InlineEscaping = 'backslash' | 'bracketed' | 'bracketed-link-target' | 'none'
|
||||||
|
|
||||||
export type NodeRange = { first: number; last: number }
|
export type NodeRange = { first: number; last: number }
|
||||||
|
|
||||||
@@ -77,10 +77,12 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
|
|||||||
const escaping = escapings[index]
|
const escaping = escapings[index]
|
||||||
const escapable = escaping === 'backslash' || escaping === 'bracketed'
|
const escapable = escaping === 'backslash' || escaping === 'bracketed'
|
||||||
if (
|
if (
|
||||||
escapable &&
|
(escapable &&
|
||||||
(claimsLineStart(line, index, container) ||
|
(claimsLineStart(line, index, container) ||
|
||||||
mergesWithSyntax(scan, escapings, index) ||
|
mergesWithSyntax(scan, escapings, index) ||
|
||||||
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))
|
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))) ||
|
||||||
|
(escaping === 'bracketed-link-target' &&
|
||||||
|
((scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) || (scan.charAt(index) === ':' && opensInlineDirective(scan, index))))
|
||||||
) {
|
) {
|
||||||
output += '\\'
|
output += '\\'
|
||||||
escaped.add(index)
|
escaped.add(index)
|
||||||
@@ -181,8 +183,8 @@ function touchesSyntax(scan: string, escapings: readonly (InlineEscaping | undef
|
|||||||
return scan.charAt(cursor) === character && isSyntax(escapings[cursor])
|
return scan.charAt(cursor) === character && isSyntax(escapings[cursor])
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSyntax(escaping: InlineEscaping | undefined): boolean {
|
export function isSyntax(escaping: InlineEscaping | undefined): boolean {
|
||||||
return escaping === 'none'
|
return escaping === 'none' || escaping === 'bracketed-link-target'
|
||||||
}
|
}
|
||||||
|
|
||||||
function opensConstruct(
|
function opensConstruct(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AdfNode } from '../../adf/document.ts'
|
import type { AdfNode } from '../../adf/document.ts'
|
||||||
import { carriesOnly } from '../../adf/document.ts'
|
import { carriesOnly, nodeContent } from '../../adf/document.ts'
|
||||||
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
|
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
|
||||||
import { tryPipeCell } from './inline-line.ts'
|
import { tryPipeCell } from './inline-line.ts'
|
||||||
import type { ConvertErrorPath } from '../../result.ts'
|
import type { ConvertErrorPath } from '../../result.ts'
|
||||||
@@ -11,7 +11,7 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
|
|||||||
for (const [rowIndex, row] of rows.entries()) {
|
for (const [rowIndex, row] of rows.entries()) {
|
||||||
const cells: string[] = []
|
const cells: string[] = []
|
||||||
for (const [cellIndex, paragraph] of row.entries()) {
|
for (const [cellIndex, paragraph] of row.entries()) {
|
||||||
const content = paragraph.content ?? []
|
const content = nodeContent(paragraph)
|
||||||
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
|
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
|
||||||
if (line === undefined) return undefined
|
if (line === undefined) return undefined
|
||||||
cells.push(line)
|
cells.push(line)
|
||||||
@@ -23,12 +23,12 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
||||||
const rows = node.content ?? []
|
const rows = nodeContent(node)
|
||||||
const columns = (rows[0]?.content ?? []).length
|
const columns = rows[0] === undefined ? 0 : nodeContent(rows[0]).length
|
||||||
if (!carriesOnly(node, []) || columns === 0) return undefined
|
if (!carriesOnly(node, []) || columns === 0) return undefined
|
||||||
const grid: AdfNode[][] = []
|
const grid: AdfNode[][] = []
|
||||||
for (const [index, row] of rows.entries()) {
|
for (const [index, row] of rows.entries()) {
|
||||||
const cells = row.content ?? []
|
const cells = nodeContent(row)
|
||||||
if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined
|
if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined
|
||||||
const wanted = index === 0 ? 'tableHeader' : 'tableCell'
|
const wanted = index === 0 ? 'tableHeader' : 'tableCell'
|
||||||
const paragraphs: AdfNode[] = []
|
const paragraphs: AdfNode[] = []
|
||||||
@@ -43,7 +43,7 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function plainParagraph(cell: AdfNode): AdfNode | undefined {
|
function plainParagraph(cell: AdfNode): AdfNode | undefined {
|
||||||
const content = cell.content ?? []
|
const content = nodeContent(cell)
|
||||||
const paragraph = content[0]
|
const paragraph = content[0]
|
||||||
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined
|
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined
|
||||||
return paragraph
|
return paragraph
|
||||||
|
|||||||
+24
-16
@@ -103,27 +103,35 @@ export function spellDestination(href: string, path: ConvertErrorPath): Result<s
|
|||||||
return success(`<${href}>`)
|
return success(`<${href}>`)
|
||||||
}
|
}
|
||||||
if (href.startsWith('<')) return failure('unspellable-link', 'a bare link destination cannot begin with an angle bracket', path)
|
if (href.startsWith('<')) return failure('unspellable-link', 'a bare link destination cannot begin with an angle bracket', path)
|
||||||
return success(escapeUnbalanced(href))
|
return success(escapeUnbalanced(href, '(', ')'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spellTitle(title: string, path: ConvertErrorPath): Result<string> {
|
export function spellLinkTarget(href: string, title: string | undefined, path: ConvertErrorPath): Result<string> {
|
||||||
|
const destination = spellDestination(href, path)
|
||||||
|
if (!destination.ok || title === undefined) return destination
|
||||||
|
const spelledTitle = spellTitle(title, path)
|
||||||
|
if (!spelledTitle.ok) return spelledTitle
|
||||||
|
return success(`${destination.value === '' ? '<>' : destination.value}${spelledTitle.value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeUnbalanced(spelling: string, opener: string, closer: string): string {
|
||||||
|
const open: number[] = []
|
||||||
|
const unbalanced = new Set<number>()
|
||||||
|
for (let index = 0; index < spelling.length; index += backslashEscape(spelling, index) === undefined ? 1 : 2) {
|
||||||
|
const character = spelling.charAt(index)
|
||||||
|
if (character === opener) open.push(index)
|
||||||
|
if (character === closer && open.pop() === undefined) unbalanced.add(index)
|
||||||
|
}
|
||||||
|
for (const index of open) unbalanced.add(index)
|
||||||
|
let spelled = ''
|
||||||
|
for (let index = 0; index < spelling.length; index += 1) spelled += (unbalanced.has(index) ? '\\' : '') + spelling.charAt(index)
|
||||||
|
return spelled
|
||||||
|
}
|
||||||
|
|
||||||
|
function spellTitle(title: string, path: ConvertErrorPath): Result<string> {
|
||||||
if (/[\n\r\\]/.test(title)) {
|
if (/[\n\r\\]/.test(title)) {
|
||||||
return failure('unspellable-link', 'no canonical escape spells a backslash or newline in a link title', path)
|
return failure('unspellable-link', 'no canonical escape spells a backslash or newline in a link title', path)
|
||||||
}
|
}
|
||||||
if (holdsEntityReference(title)) return failure('unspellable-link', 'a link title holds an entity reference that decodes on the way back', path)
|
if (holdsEntityReference(title)) return failure('unspellable-link', 'a link title holds an entity reference that decodes on the way back', path)
|
||||||
return success(` "${title.replaceAll('"', '\\"')}"`)
|
return success(` "${title.replaceAll('"', '\\"')}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeUnbalanced(href: string): string {
|
|
||||||
const open: number[] = []
|
|
||||||
const unbalanced = new Set<number>()
|
|
||||||
for (let index = 0; index < href.length; index += 1) {
|
|
||||||
const character = href.charAt(index)
|
|
||||||
if (character === '(') open.push(index)
|
|
||||||
if (character === ')' && open.pop() === undefined) unbalanced.add(index)
|
|
||||||
}
|
|
||||||
for (const index of open) unbalanced.add(index)
|
|
||||||
let spelled = ''
|
|
||||||
for (let index = 0; index < href.length; index += 1) spelled += (unbalanced.has(index) ? '\\' : '') + href.charAt(index)
|
|
||||||
return spelled
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AdfMark } from '../adf/document.ts'
|
|||||||
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||||
import type { MarkType } from '../adf/mark-attributes.ts'
|
import type { MarkType } from '../adf/mark-attributes.ts'
|
||||||
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
|
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
|
||||||
|
import { nodeAttrs } from '../adf/document.ts'
|
||||||
import { spellAttributes, spellVocabulary } from './directive-syntax.ts'
|
import { spellAttributes, spellVocabulary } from './directive-syntax.ts'
|
||||||
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
||||||
|
|
||||||
@@ -30,6 +31,6 @@ export function markSpelling(type: string): MarkSpelling | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined {
|
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined {
|
||||||
const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, [])
|
const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, [])
|
||||||
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
|
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
claimsPipeLine,
|
claimsPipeLine,
|
||||||
closingCodeFence,
|
closingCodeFence,
|
||||||
decodeTextEscapes,
|
decodeTextEscapes,
|
||||||
|
isBlankLine,
|
||||||
isThematicBreak,
|
isThematicBreak,
|
||||||
listMarker,
|
listMarker,
|
||||||
markerInterruptsParagraph,
|
markerInterruptsParagraph,
|
||||||
@@ -61,7 +62,6 @@ type Line = { column: number; text: string }
|
|||||||
|
|
||||||
type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; position: SourcePosition; stack: OpenContainer[] }
|
type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; position: SourcePosition; stack: OpenContainer[] }
|
||||||
|
|
||||||
const blankLine = /^[ \t]*$/
|
|
||||||
const indentedCodeColumns = 4
|
const indentedCodeColumns = 4
|
||||||
const largestOpenerIndentation = 3
|
const largestOpenerIndentation = 3
|
||||||
const leafColons = 2
|
const leafColons = 2
|
||||||
@@ -117,7 +117,7 @@ function continuesContainer(walk: Walk, container: OpenContainer, line: Line): L
|
|||||||
// A directive container has no continuation marker: only its own fence closes it.
|
// A directive container has no continuation marker: only its own fence closes it.
|
||||||
if (container.kind === 'directive') return line
|
if (container.kind === 'directive') return line
|
||||||
// A list item begins with at most one blank line: an empty one gives the second up.
|
// A list item begins with at most one blank line: an empty one gives the second up.
|
||||||
if (blankLine.test(line.text)) {
|
if (isBlankLine(line.text)) {
|
||||||
return container.blocks.length === 0 && walk.leaf === undefined ? undefined : { column: line.column, text: '' }
|
return container.blocks.length === 0 && walk.leaf === undefined ? undefined : { column: line.column, text: '' }
|
||||||
}
|
}
|
||||||
return leadingColumns(line) < container.indentation ? undefined : removeColumns(line, container.indentation)
|
return leadingColumns(line) < container.indentation ? undefined : removeColumns(line, container.indentation)
|
||||||
@@ -155,7 +155,7 @@ function itemStart(line: Line, opener: Line, paragraphOpen: boolean, enclosing:
|
|||||||
const marker = listMarker(opener.text)
|
const marker = listMarker(opener.text)
|
||||||
if (marker === undefined) return undefined
|
if (marker === undefined) return undefined
|
||||||
const after: Line = { column: opener.column + marker.width, text: opener.text.slice(marker.width) }
|
const after: Line = { column: opener.column + marker.width, text: opener.text.slice(marker.width) }
|
||||||
const blank = blankLine.test(after.text)
|
const blank = isBlankLine(after.text)
|
||||||
if (paragraphOpen && !markerInterruptsParagraph(marker.start, blank)) return undefined
|
if (paragraphOpen && !markerInterruptsParagraph(marker.start, blank)) return undefined
|
||||||
const spaces = leadingColumns(after)
|
const spaces = leadingColumns(after)
|
||||||
const padding = blank || spaces > indentedCodeColumns ? 1 : spaces
|
const padding = blank || spaces > indentedCodeColumns ? 1 : spaces
|
||||||
@@ -268,7 +268,7 @@ function pushFault(walk: Walk, fault: ConvertFault): void {
|
|||||||
|
|
||||||
// A claimed line ends the lazy continuation CommonMark would fold it into (spec/flavour.md).
|
// A claimed line ends the lazy continuation CommonMark would fold it into (spec/flavour.md).
|
||||||
function continuesLazily(walk: Walk, line: Line): boolean {
|
function continuesLazily(walk: Walk, line: Line): boolean {
|
||||||
if (walk.leaf?.kind !== 'paragraph' || blankLine.test(line.text)) return false
|
if (walk.leaf?.kind !== 'paragraph' || isBlankLine(line.text)) return false
|
||||||
if (leadingColumns(line) >= indentedCodeColumns) return true
|
if (leadingColumns(line) >= indentedCodeColumns) return true
|
||||||
const opener = removeColumns(line, largestOpenerIndentation).text
|
const opener = removeColumns(line, largestOpenerIndentation).text
|
||||||
if (claimsDirectiveLine(opener) || claimsPipeLine(opener) || isThematicBreak(opener)) return false
|
if (claimsDirectiveLine(opener) || claimsPipeLine(opener) || isThematicBreak(opener)) return false
|
||||||
@@ -283,7 +283,7 @@ function readBlockLine(walk: Walk, line: Line): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (leaf?.kind === 'html') {
|
if (leaf?.kind === 'html') {
|
||||||
if (leaf.closer === undefined ? blankLine.test(line.text) : leaf.closer.test(line.text)) closeLeaf(walk)
|
if (leaf.closer === undefined ? isBlankLine(line.text) : leaf.closer.test(line.text)) closeLeaf(walk)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (leaf?.kind === 'pipe-table') {
|
if (leaf?.kind === 'pipe-table') {
|
||||||
@@ -298,7 +298,7 @@ function readBlockLine(walk: Walk, line: Line): void {
|
|||||||
if (readIndentedCodeLine(leaf, line)) return
|
if (readIndentedCodeLine(leaf, line)) return
|
||||||
closeLeaf(walk)
|
closeLeaf(walk)
|
||||||
}
|
}
|
||||||
if (blankLine.test(line.text)) {
|
if (isBlankLine(line.text)) {
|
||||||
closeLeaf(walk)
|
closeLeaf(walk)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -310,7 +310,7 @@ function readBlockLine(walk: Walk, line: Line): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readIndentedCodeLine(leaf: Extract<OpenLeaf, { kind: 'indented-code' }>, line: Line): boolean {
|
function readIndentedCodeLine(leaf: Extract<OpenLeaf, { kind: 'indented-code' }>, line: Line): boolean {
|
||||||
if (blankLine.test(line.text)) {
|
if (isBlankLine(line.text)) {
|
||||||
leaf.held.push(removeColumns(line, indentedCodeColumns).text)
|
leaf.held.push(removeColumns(line, indentedCodeColumns).text)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +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 { attributeNestingMessage, nodeMarks } 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'
|
||||||
@@ -89,7 +89,7 @@ function blockSpellingFault(name: string): ConvertFault | undefined {
|
|||||||
function slotText(content: readonly AdfNode[]): string | undefined {
|
function slotText(content: readonly AdfNode[]): string | undefined {
|
||||||
if (content.length === 0) return ''
|
if (content.length === 0) return ''
|
||||||
const only = content.length === 1 ? content[0] : undefined
|
const only = content.length === 1 ? content[0] : undefined
|
||||||
if (only?.type !== 'text' || (only.marks ?? []).length > 0 || typeof only.text !== 'string') return undefined
|
if (only?.type !== 'text' || nodeMarks(only).length > 0 || typeof only.text !== 'string') return undefined
|
||||||
return only.text
|
return only.text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.t
|
|||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
import { inlineDirective } from '../../adf/inline-directives.ts'
|
import { inlineDirective } from '../../adf/inline-directives.ts'
|
||||||
import { mergeAdjacentText } from '../../adf/editor-normal.ts'
|
import { mergeAdjacentText } from '../../adf/editor-normal.ts'
|
||||||
|
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||||
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
|
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
|
||||||
import { readCarriedInline } from '../opaque-carry.ts'
|
import { readCarriedInline } from '../opaque-carry.ts'
|
||||||
import { readDirectiveMark } from './directive-marks.ts'
|
import { readDirectiveMark } from './directive-marks.ts'
|
||||||
@@ -337,7 +338,7 @@ function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<strin
|
|||||||
function altText(node: AdfNode): string {
|
function altText(node: AdfNode): string {
|
||||||
if (node.type === 'hardBreak') return ' '
|
if (node.type === 'hardBreak') return ' '
|
||||||
const slot = inlineDirective(node.type)?.textAttribute
|
const slot = inlineDirective(node.type)?.textAttribute
|
||||||
const spelled = slot === undefined ? undefined : node.attrs?.[slot]
|
const spelled = slot === undefined ? undefined : nodeAttrs(node)[slot]
|
||||||
return typeof spelled === 'string' ? spelled : (node.text ?? '')
|
return typeof spelled === 'string' ? spelled : (node.text ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +410,7 @@ function markType(character: string, used: number): string {
|
|||||||
// A node cannot carry one mark type twice (AGENTS.md §14).
|
// A node cannot carry one mark type twice (AGENTS.md §14).
|
||||||
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
|
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
|
||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
const marks = node.marks ?? []
|
const marks = nodeMarks(node)
|
||||||
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
|
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { failure, faulted, positioned, success, type ConvertErrorPath, type Pars
|
|||||||
import { languageSlot } from '../code-language.ts'
|
import { languageSlot } from '../code-language.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { listBreakName, listBreakSpelling } from '../list-break.ts'
|
import { listBreakName, listBreakSpelling } from '../list-break.ts'
|
||||||
|
import { nodeAttrs } from '../../adf/document.ts'
|
||||||
import { parseBlocks } from './blocks.ts'
|
import { parseBlocks } from './blocks.ts'
|
||||||
import { parseInlineContent } from './inline-content.ts'
|
import { parseInlineContent } from './inline-content.ts'
|
||||||
import { readBlockDirectiveNode } from './directive-nodes.ts'
|
import { readBlockDirectiveNode } from './directive-nodes.ts'
|
||||||
@@ -106,7 +107,7 @@ function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, de
|
|||||||
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
|
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
|
||||||
const only = blocks.length === 1 ? blocks[0] : undefined
|
const only = blocks.length === 1 ? blocks[0] : undefined
|
||||||
if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body: this body is not one`, path)
|
if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body: this body is not one`, path)
|
||||||
const attribute = node.attrs?.['language']
|
const attribute = nodeAttrs(node)['language']
|
||||||
const fromFence = only.language !== ''
|
const fromFence = only.language !== ''
|
||||||
const slot = languageSlot(fromFence ? only.language : attribute)
|
const slot = languageSlot(fromFence ? only.language : attribute)
|
||||||
if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {
|
if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {
|
||||||
|
|||||||
+85
-3
@@ -154,9 +154,19 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
|
|||||||
2f raises what 1d's unspelled block separation costs: a single `localId` on a paragraph
|
2f raises what 1d's unspelled block separation costs: a single `localId` on a paragraph
|
||||||
beside a plain one now refuses every container body that is a directive's — a panel, an
|
beside a plain one now refuses every container body that is a directive's — a panel, an
|
||||||
expand, a table cell — where before 2f the attribute refused the document anyway.
|
expand, a table cell — where before 2f the attribute refused the document anyway.
|
||||||
|
- [x] **3 — `markdownToAdf` (`0.1.0`).** Each sub-item lands the fixtures its own code reads, and
|
||||||
Under **3 — `markdownToAdf` (`0.1.0`)**:
|
the runner grows a parse half as they do: readers for `corpus/normalization/` (setext,
|
||||||
|
indented code, loose lists, `*`/`+`
|
||||||
|
bullets, entity references, soft wraps — one-way, the markdown not canonical) and
|
||||||
|
`corpus/errors/` (a markdown input per named error, the code in a `.error` beside it) with
|
||||||
|
the first fixture each. `commonmark-subset/` cannot be the first to green — `::paragraph`
|
||||||
|
and `:hardBreak{}` sit in it — so 3b through 3f answer to their own tests and the one-way
|
||||||
|
fixtures they land, and 3g is where the first directory reads back. The raw-HTML element
|
||||||
|
mapping is empty until milestone 6, so at `0.1.0` every raw-HTML construct in input — a
|
||||||
|
block, an inline tag, a comment, a processing instruction — is a named error. Input is where
|
||||||
|
unbounded nesting actually arrives, so §11's 500 binds all three of the emitter's guards
|
||||||
|
here: block depth at 3c and again at 3f's container fences, inline and mark depth at 3f and
|
||||||
|
3i, a carried value's JSON at 3j, where `isJsonValue` already bounds it.
|
||||||
- [x] **3a — The hierarchy.** Mechanical, ahead of the first parser file: `src/adf/` and
|
- [x] **3a — The hierarchy.** Mechanical, ahead of the first parser file: `src/adf/` and
|
||||||
`src/markdown/` (`html/` arrives with its first file, 6-7), the grammar module shared
|
`src/markdown/` (`html/` arrives with its first file, 6-7), the grammar module shared
|
||||||
inside `markdown/`, and `emphasis-matching.ts` beside it — the parser reuses it whole,
|
inside `markdown/`, and `emphasis-matching.ts` beside it — the parser reuses it whole,
|
||||||
@@ -386,6 +396,37 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
|
|||||||
`index.ts` gains `markdownToAdf` here, and the README's status line with it: this is the
|
`index.ts` gains `markdownToAdf` here, and the README's status line with it: this is the
|
||||||
last parser chunk, so `parsingDirectories` becomes `emittingDirectories` and the whole
|
last parser chunk, so `parsingDirectories` becomes `emittingDirectories` and the whole
|
||||||
corpus round-trips both ways — `0.1.0`'s proof, which 4 widens rather than replaces.
|
corpus round-trips both ways — `0.1.0`'s proof, which 4 widens rather than replaces.
|
||||||
|
- [x] **3k — The CommonMark spec suite (`0.2.0`).** Checked in at `corpus/commonmark-spec/`,
|
||||||
|
pinned to the version it ships — the one `commonmark-grammar.ts` names for its start
|
||||||
|
conditions — `corpus/README.md` gaining the kind.
|
||||||
|
**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
|
||||||
|
canonical fixpoint: a named error, or markdown that parses and emits to itself byte for
|
||||||
|
byte. That HTML's text, tags stripped and entities decoded, against the parsed document's
|
||||||
|
concatenated `text`. And a count of the dozen elements the CommonMark subset covers
|
||||||
|
against the marks and nodes they map to — counting distinct mark types per text node, since
|
||||||
|
3e collapses a spelling nested inside its own kind and `*(*a*)*` is two `<em>` against one
|
||||||
|
`em`. The fixpoint alone is self-consistency a parser
|
||||||
|
returning the empty document passes, and the text alone one dropping every emphasis; the
|
||||||
|
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
|
||||||
|
the reference HTML gives two `<ul>` counts one `bulletList`. One outcome is no
|
||||||
|
exception and must not be filed as one: a fixable §2 hole — valid CommonMark parsing to a
|
||||||
|
document `adfToMarkdown` refuses — which is what `corpus/unspellable/` held until 3c, 3e
|
||||||
|
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`).**
|
||||||
|
- [x] **4.1 — Editor-normal and the node accessors.** `toEditorNormal(doc)` in
|
||||||
|
`src/adf/editor-normal.ts`, on 3i's merging: adjacent text nodes carrying identical marks and
|
||||||
|
no attributes merged, an empty `attrs`, `marks` or `content` the absent key, `-0` read as `0`
|
||||||
|
(§2); the round-trip tests compare the parser's output through it, and `serializeCanonicalJson`
|
||||||
|
beneath it walks iteratively. `nodeContent`/`nodeAttrs`/`nodeMarks` replace the 46 inline
|
||||||
|
`?? []`/`?? {}` reads in `src/` (23 `content`, 12 `marks`, 11 `attrs`) and the `attrs?.[key]`
|
||||||
|
reads, and the branch floor rises to the integer floor of what the suite then measures.
|
||||||
|
**Settled** (the maintainer, 2026-09-14): a text node carrying attributes never merges —
|
||||||
|
`0.1.0` merged a carried one into its neighbour on read-back — and the fix lands here, as does
|
||||||
|
the iterative serializer.
|
||||||
- [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being
|
- [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being
|
||||||
the published identity: `package.json` `name` and `repository`, the Gitea repo and its
|
the published identity: `package.json` `name` and `repository`, the Gitea repo and its
|
||||||
remote, the README title, §6's published-as line, the checkout directory.
|
remote, the README title, §6's published-as line, the checkout directory.
|
||||||
@@ -532,3 +573,44 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
|
|||||||
`instrumentisto/geckodriver`, currency over size — the leg's whole worth is a real
|
`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
|
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.
|
Firefox majors behind with a publisher that may go quiet while Renovate stays silent.
|
||||||
|
- [x] **11 — Atlassian's ADF schema as the tables' truth (`0.2.0`).** `@atlaskit/adf-schema`'s two
|
||||||
|
JSON Schemas vendored rather than the package installed (AGENTS.md §5), and the node tables
|
||||||
|
gated against them (§10). **Settled** (the maintainer, 2026-09-13): vendored at
|
||||||
|
`spec/adf-schema/` and re-pinned by hand when a need shows; the gate compares attribute names
|
||||||
|
and kinds, never value sets, over `full.json` and `stage-0.json` together.
|
||||||
|
- [x] **11a — The vendored schema.** `full.json` and `stage-0.json`, byte-exact from
|
||||||
|
`@atlaskit/adf-schema@57.4.9`'s `dist/json-schema/v1/`, at `spec/adf-schema/`, each pinned
|
||||||
|
by its SHA-256 in a test the way `spec.json` is. The version, the source and the Apache-2.0
|
||||||
|
attribution sit beside them with the licence text; no gate re-serializes either file.
|
||||||
|
- [x] **11b — The gate.** For each node and mark type the tables spell, the attribute names and
|
||||||
|
kinds equal the union over every definition in both files whose `type` enum names it,
|
||||||
|
`anyOf`/`allOf` branches included, the argument slot (`panelType`, `state`) counting as
|
||||||
|
spelled. Kinds: `string`; `number`, `integer` included; `boolean`; `json` for an object, an
|
||||||
|
array or an untyped value; an `enum`-only attribute takes its values' kind. What the schema
|
||||||
|
holds past the tables is pinned in two exact lists — an entry the schema no longer needs is
|
||||||
|
red, like a difference neither list names: gaps, attributes of a spelled type (57.4.9:
|
||||||
|
`link` `collection` `id` `occurrenceKey`, `rule` `color` `style` `weight`, `layoutSection`
|
||||||
|
`columnRuleStyle`), emptied by 13; and carried, types the tables do not spell (`alignment`
|
||||||
|
`annotation` `backgroundColor` `blockCard` `bodiedRule` `breakout` `dataConsumer`
|
||||||
|
`embedCard` `fontSize` `fragment` `indentation` `inlineExtension` `placeholder`), `doc` and
|
||||||
|
`text` counting as the grammar's own.
|
||||||
|
|
||||||
|
## 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,9 +5,11 @@ 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, 5c, 5d, 5 → `0.1.0`; 4b, 4c and 4d → `0.1.1`; 4, 3k → `0.2.0`;
|
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b, 4c, 10, 5g → `0.2.0`; 4d, 5f → `0.2.1`;
|
||||||
6, 7 → `0.3.0`.
|
6, 7 → `0.3.0`; 9 → TBD; 5e last.
|
||||||
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. `0.2.0`'s order is settled
|
||||||
|
(the maintainer, 2026-09-13): 11 makes the tables 4 generates from answer to Atlassian's schema, 4
|
||||||
|
proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c change.
|
||||||
|
|
||||||
- [x] **0 — Scaffold.**
|
- [x] **0 — Scaffold.**
|
||||||
- [x] **1a — The directive grammar.**
|
- [x] **1a — The directive grammar.**
|
||||||
@@ -29,19 +31,7 @@ The numbering is the order the work was planned in, not the order it ships.
|
|||||||
- [x] **2e4 — The carry's fallback triggers.**
|
- [x] **2e4 — The carry's fallback triggers.**
|
||||||
- [x] **2e5 — Combined documents and the collision property.**
|
- [x] **2e5 — Combined documents and the collision property.**
|
||||||
- [x] **2f — The attributes CommonMark cannot hold.**
|
- [x] **2f — The attributes CommonMark cannot hold.**
|
||||||
- [ ] **3 — `markdownToAdf` (`0.1.0`).** Each sub-item lands the fixtures its own code reads, and
|
- [x] **3 — `markdownToAdf`.**
|
||||||
the runner grows a parse half as they do: readers for `corpus/normalization/` (setext,
|
|
||||||
indented code, loose lists, `*`/`+`
|
|
||||||
bullets, entity references, soft wraps — one-way, the markdown not canonical) and
|
|
||||||
`corpus/errors/` (a markdown input per named error, the code in a `.error` beside it) with
|
|
||||||
the first fixture each. `commonmark-subset/` cannot be the first to green — `::paragraph`
|
|
||||||
and `:hardBreak{}` sit in it — so 3b through 3f answer to their own tests and the one-way
|
|
||||||
fixtures they land, and 3g is where the first directory reads back. The raw-HTML element
|
|
||||||
mapping is empty until milestone 6, so at `0.1.0` every raw-HTML construct in input — a
|
|
||||||
block, an inline tag, a comment, a processing instruction — is a named error. Input is where
|
|
||||||
unbounded nesting actually arrives, so §11's 500 binds all three of the emitter's guards
|
|
||||||
here: block depth at 3c and again at 3f's container fences, inline and mark depth at 3f and
|
|
||||||
3i, a carried value's JSON at 3j, where `isJsonValue` already bounds it.
|
|
||||||
- [x] **3a — The hierarchy.**
|
- [x] **3a — The hierarchy.**
|
||||||
- [x] **3b — The leaf blocks.**
|
- [x] **3b — The leaf blocks.**
|
||||||
- [x] **3c — The container blocks.**
|
- [x] **3c — The container blocks.**
|
||||||
@@ -52,39 +42,46 @@ The numbering is the order the work was planned in, not the order it ships.
|
|||||||
- [x] **3h — The block nodes.**
|
- [x] **3h — The block nodes.**
|
||||||
- [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/`,
|
- [x] **3k — The CommonMark spec suite.**
|
||||||
pinned to the version it ships — the one `html-blocks.ts` names for its start
|
|
||||||
conditions — `corpus/README.md` gaining the kind.
|
|
||||||
**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
|
|
||||||
canonical fixpoint: a named error, or markdown that parses and emits to itself byte for
|
|
||||||
byte. That HTML's text, tags stripped and entities decoded, against the parsed document's
|
|
||||||
concatenated `text`. And a count of the dozen elements the CommonMark subset covers
|
|
||||||
against the marks and nodes they map to — counting distinct mark types per text node, since
|
|
||||||
3e collapses a spelling nested inside its own kind and `*(*a*)*` is two `<em>` against one
|
|
||||||
`em`. The fixpoint alone is self-consistency a parser
|
|
||||||
returning the empty document passes, and the text alone one dropping every emphasis; the
|
|
||||||
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
|
|
||||||
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
|
|
||||||
`adfToMarkdown` refuses is a §2 hole, which is what `corpus/unspellable/` held until 3c,
|
|
||||||
3e and 3h landed their answers and emptied it.
|
|
||||||
- [ ] **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.
|
||||||
finished here, on 3i's merging — `toEditorNormal(doc)` and the equality the round-trip
|
**Settled** (the maintainer, 2026-09-13): `fast-check` generates and shrinks. The gate runs a
|
||||||
asserts, which over normalized input is the canonical serializer's compact spelling —
|
fixed seed, the properties together adding about five seconds per engine; an environment
|
||||||
rather than staying spelled inline as `?? []` at every reader. The
|
variable raises the runs and randomizes the seed for local digging, and a counterexample
|
||||||
reading half is `nodeContent`/`nodeAttrs`/`nodeMarks` over the ~28 sites spelling it
|
found becomes a round-trip fixture. The generators draw from the node tables — each node's
|
||||||
inline today, which also lifts the branch floor §10 keeps below 100 for exactly those
|
content model and attribute vocabulary as `adf/` records them, which 11 holds to Atlassian's
|
||||||
halves.
|
schema — and misplace a share of nodes so the carry (§3) is exercised; no JSON Schema walker
|
||||||
Generators emit editor-normal ADF (§2). Real sanitized ADF from live Atlassian APIs lands
|
enters the tests. `toEditorNormal` stays internal. 2e5's collision test goes, since a
|
||||||
here too (§10), in `corpus/real-payloads/`: an ADF→markdown→ADF check with no expected
|
collision already fails the round-trip on the same fixtures; the fixture-duplicate test
|
||||||
markdown, the payloads supplied by the maintainer. This subsumes 2e5's collision property —
|
stays.
|
||||||
a document that round-trips proves no other document shares its spelling — so decide here
|
- [x] **4.1 — Editor-normal and the node accessors.**
|
||||||
whether that gate stays as the parser-free, faster-failing signal or goes; the half holding
|
- [ ] **4.2 — The ADF property.** `fast-check` joins `devDependencies`, AGENTS.md §5 naming what
|
||||||
no fixture duplicates is hygiene rather than a round-trip claim, and stays either way.
|
it earns — shrinking a failing document to the nodes that break it — and §10 the properties
|
||||||
- [ ] **4b — The block walk's retry (`0.1.1`).** `emitBlock` walks a subtree twice wherever
|
beside the corpus. A generated editor-normal document either refuses in `adfToMarkdown`
|
||||||
|
with a `ConvertError` or reads back through `markdownToAdf` to an equal document, and
|
||||||
|
nothing throws, under Node, Deno and Bun alike. 2e5's collision test is deleted.
|
||||||
|
**Settled** (the maintainer, 2026-09-14): about half the block positions draw attribute-less
|
||||||
|
CommonMark shapes — single-type lists, headings, blockquotes, pipe-table-shaped tables — where
|
||||||
|
the escaping lives. A round-trip break the property finds is fixed inside 4.2, one commit per
|
||||||
|
break with its round-trip fixture seen red first, and 4.2 lands when a deep run of about
|
||||||
|
10,000 per engine passes clean; a break needing design goes to the maintainer. The first two,
|
||||||
|
both shipped in `0.1.0`: an empty `href` with a title spelled `[a]( "")`, which reads back as
|
||||||
|
the href `""`, and a `[` or `]` in a link's destination or title inside a directive mark,
|
||||||
|
refused as the emitter's own output or, with `]`, losing the link. Later, settled the same
|
||||||
|
day: an autolink whose href holds a backtick takes the `[text](url)` form inside a
|
||||||
|
directive's content; and a V8 fault the deep runs hit — once `JSON.parse` has read a key
|
||||||
|
holding an escaped backslash, a later escaped quote or newline key comes back as that
|
||||||
|
backslash, on Node and Deno but not Bun — is accepted rather than worked around, since the
|
||||||
|
library only refuses such a document, so the generators' JSON keys avoid those characters and
|
||||||
|
the maintainer reports the fault upstream.
|
||||||
|
- [ ] **4.3 — The markdown property.** Generated markdown through `markdownToAdf` never throws,
|
||||||
|
and the runs fit the budget; where it parses and `adfToMarkdown` spells the result, that
|
||||||
|
spelling parses and emits to itself byte for byte (§2).
|
||||||
|
- [ ] **4.4 — The real payloads.** `corpus/real-payloads/` holds the maintainer's sanitized
|
||||||
|
payloads, each round-tripped ADF→markdown→ADF with no expected markdown. It waits on the
|
||||||
|
maintainer dropping the `.json` files untracked into the main checkout's
|
||||||
|
`corpus/real-payloads/` (the maintainer, 2026-09-14); the chunk moves them onto its branch.
|
||||||
|
- [ ] **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
|
||||||
@@ -100,7 +97,7 @@ The numbering is the order the work was planned in, not the order it ships.
|
|||||||
export persona runs in bulk walks the document twice. Both walks are linear, so this is a
|
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
|
constant factor rather than 4b's class change, and the parting is what gives depth its own
|
||||||
code (§8) — measure before joining them back.
|
code (§8) — measure before joining them back.
|
||||||
- [ ] **4c — The scanning rule's remaining sites (`0.1.1`).** A trailing-anchored regex re-walks
|
- [ ] **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
|
||||||
@@ -112,7 +109,7 @@ 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.
|
||||||
- [ ] **4d — What the gate says while it runs (`0.1.1`).** `ci.sh` runs nine legs and announces
|
- [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces
|
||||||
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
|
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
|
||||||
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its
|
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its
|
||||||
own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and
|
own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and
|
||||||
@@ -126,19 +123,41 @@ The numbering is the order the work was planned in, not the order it ships.
|
|||||||
rather than promoting the gate's `dist`, and unmeasured until the log shows them. Per-leg
|
rather than promoting the gate's `dist`, and unmeasured until the log shows them. Per-leg
|
||||||
timing is what turns "slow or hung" from a guess into a reading; the browser leg's own
|
timing is what turns "slow or hung" from a guess into a reading; the browser leg's own
|
||||||
5.4–7.9s against a 17s warm gate is the number that made it obviously cheap.
|
5.4–7.9s against a 17s warm gate is the number that made it obviously cheap.
|
||||||
- [ ] **5 — Ship `0.1.0`.** Only the maintainer's own acts are left (§15): make the Gitea repo
|
- [x] **5 — Ship `0.1.0`.**
|
||||||
public (§6), create the `NPM_TOKEN` secret, confirm the Actions token may push tags — the
|
- [ ] **5e — The publish token's deadline.** `0.1.0` published only once the npm
|
||||||
publish succeeds and the tag push then reddens the run, though the next push to `main`
|
token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm
|
||||||
retries the tag alone — and open the bump PR that sets `version` to `0.1.0` and drops
|
answered `EOTP` until the token itself bypassed. npm retires bypass-2FA tokens for direct
|
||||||
`private: true`, the guard against any earlier publish. The bump and the drop go in one
|
publishing around January 2027, leaving them `npm stage publish`, which a maintainer
|
||||||
commit: dropping `private` alone publishes `0.0.0`, which also differs from npm's nothing. `0.1.0` is the
|
approves with 2FA; its replacement — trusted publishing over OIDC — supports GitHub-hosted
|
||||||
markdown round-trip: both markdown directions, the types, `isAdfDocument`, proved over the
|
Actions, GitLab.com's shared runners and CircleCI's cloud, self-hosted runners planned
|
||||||
checked-in corpus.
|
without a date. So the release path has an expiry date and no drop-in successor yet. Revisit:
|
||||||
**Settled** (the maintainer, 2026-09-01): the round-trip proved over the checked-in corpus
|
whether npm has added Gitea or self-hosted OIDC, and otherwise whether the
|
||||||
is what `0.1.0` ships on, and the open-ended proof work follows it rather than gating it —
|
release moves to the staged publish — which fits badly with publish-on-merge,
|
||||||
3k's spec suite and 4's generators and maintainer-supplied payloads are `0.2.0`, 4b's retry
|
and is the trade to weigh rather than discover on a red release run.
|
||||||
`0.1.1`. A consumer using the library is worth more than a wider proof nobody has needed
|
**Settled** (the maintainer, 2026-09-13): last of the known work, clear of `0.2.0`, placed
|
||||||
yet, and §8's pre-1.0 rules cover what the wider proof then finds.
|
there knowing the cutoff may land before `0.2.0` ships.
|
||||||
|
- [ ] **5f — Publish the bundle size (`0.2.1`).** Measure the shipped artifact and put the number in the
|
||||||
|
README, kept honest by the release pipeline rather than by a human re-reading it. The
|
||||||
|
quantity is what a consumer downloads and loads: the tarball `npm pack` produces, its
|
||||||
|
unpacked `dist`, and the built JavaScript minified + gzipped — the figure the competitors
|
||||||
|
advertise (marklassian's "12kb") and the only apple-to-apple one, since ours ships tsc's
|
||||||
|
unminified output and no minifier yet (decide here whether to minify for the build or report
|
||||||
|
the unminified gzip). A publish/pipeline leg measures it and fails when the README figure
|
||||||
|
drifts, so the number can't rot; the figure lands in README §The package beside the
|
||||||
|
"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.
|
||||||
|
**Settled** (the maintainer, 2026-09-13): the background goes entirely, no endpoint, ticket or
|
||||||
|
"why" note left. The top follows the package-README order: an npm version badge and the Gitea
|
||||||
|
Actions badge, a tagline that is also `package.json`'s `description`, a feature list and a
|
||||||
|
one-line table of contents, then install and the shortest runnable example; a table of
|
||||||
|
everything exported sits near the bottom. The HTML directions are one aside line under the API
|
||||||
|
until `0.3.0` ships them, the `// 0.3.0` signatures and the `0.3.0` guarantee going until then.
|
||||||
|
The tagline and `description` read "Lossless conversion between Atlassian Document Format and
|
||||||
|
extended markdown" until 7 restores HTML.
|
||||||
- [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.**
|
||||||
@@ -150,8 +169,149 @@ The numbering is the order the work was planned in, not the order it ships.
|
|||||||
- [ ] **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). The
|
||||||
|
README's tagline and `package.json`'s `description` regain HTML (5g).
|
||||||
- [ ] **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`).** Markdown other tools render readably, to and from ADF,
|
||||||
|
keeping the content while dropping what markdown cannot hold — format, design and the richer
|
||||||
|
nodes.
|
||||||
|
**Settled** (the maintainer, 2026-09-14): two exports composed around the lossless pair, so §1's
|
||||||
|
four conversions stay four. `adfToPlainMarkdown(doc)` reduces the document ADF→ADF and hands it
|
||||||
|
to `adfToMarkdown`; `plainMarkdownToAdf(markdown)` hands the markdown to `markdownToAdf` and
|
||||||
|
lifts the result ADF→ADF. Both carry markdown conventions, so the reduction sits in
|
||||||
|
`src/markdown/emit/`, the lift in `src/markdown/parse/` and what both read in `src/markdown/`
|
||||||
|
(§11). The markdown is the flavour without directives — CommonMark, the pipe table and `~~` —
|
||||||
|
plus the conventions below, chosen for readability from a survey of GitHub, GitLab, Gitea,
|
||||||
|
Obsidian, Pandoc, MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and
|
||||||
|
Discord, GitHub's renderer confirming each shape. Writing refuses only what the document guard
|
||||||
|
refuses (`not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`)
|
||||||
|
and degrades every other shape; reading refuses what `markdownToAdf` refuses. A lifted node
|
||||||
|
carries no `localId`. The lift also reads other tools' spellings — type words in any case,
|
||||||
|
Obsidian's aliases, `[X]` — since it reads their output and never writes those spellings.
|
||||||
|
- A `panel` is an alert: the marker alone on the quote's first line, a blank `>`, then the body
|
||||||
|
(`> [!WARNING]`), in GitHub's five words by colour — info `NOTE`, note `IMPORTANT`, tip and
|
||||||
|
success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE`. The lift reads those words
|
||||||
|
back (`NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error) and
|
||||||
|
Obsidian's by meaning (hint tip; success, check and done success; attention warning; danger,
|
||||||
|
failure, fail, missing and bug error; any other word info). Text after a marker in its
|
||||||
|
paragraph is the panel's first body paragraph.
|
||||||
|
- An `expand` or `nestedExpand` is Obsidian's folded callout, `> [!NOTE]- Title`, a blank `>`,
|
||||||
|
then the body. The lift reads a fold sign (`-` or `+`) as an expand whatever the word, the
|
||||||
|
rest of the marker's paragraph as its title, and an expand inside an expand as a
|
||||||
|
`nestedExpand`.
|
||||||
|
- A `taskList` is a bullet list whose items lead with `[x]` or `[ ]` (`- [x] Write the spec`).
|
||||||
|
The lift reads a list whose every item is so marked back as a `taskList` — a `blockTaskItem`
|
||||||
|
where an item holds more than one block, a nested task list moved beside its item — and
|
||||||
|
leaves mixed and ordered lists plain. A `decisionList` is a plain bullet list.
|
||||||
|
- `backgroundColor` is `==text==`, and the lift gives `==text==` the Atlassian editor's default
|
||||||
|
highlight colour.
|
||||||
|
- `layoutSection`/`layoutColumn`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`
|
||||||
|
and `extensionFrame` unwrap to their body blocks in order; the CommonMark blocks keep their
|
||||||
|
spelling, attributes dropped.
|
||||||
|
- `mention` and `status` become their text, the mention's `@` kept; `emoji` its text or else its
|
||||||
|
`shortName`; `date` its ISO date in UTC (`2026-09-13`); `inlineCard`, `blockCard` and
|
||||||
|
`embedCard` a link to their `url`, dropped when they carry only `data`; a `mediaSingle`
|
||||||
|
holding an external image stays ``; `media`, `mediaGroup` and `mediaInline` their
|
||||||
|
`alt` text or nothing; `caption` its text as a paragraph; `extension`, `inlineExtension` and
|
||||||
|
`syncBlock` their `text` attribute or nothing; `placeholder` nothing; a node no row names, or
|
||||||
|
one standing where no spelling holds it, its blocks or its text.
|
||||||
|
- A table stays a pipe table: the first row becomes the header, a cell's blocks join on one line
|
||||||
|
with spaces, and spans and the cells they cover drop.
|
||||||
|
- `code`, `em`, `link`, `strike` and `strong` stay and every other mark drops, keeping its text —
|
||||||
|
`subsup` too, since `~2~` is a strike on GitHub; a link no CommonMark escape writes becomes its
|
||||||
|
text, and a mark run CommonMark's flanking or matching cannot spell drops its mark.
|
||||||
|
- A newline in text becomes a hard break and edge whitespace is trimmed; carriage returns and
|
||||||
|
null characters are removed; a paragraph line opening with a code span whose backticks would
|
||||||
|
read as a fence loses the code mark; an empty paragraph drops, and adjacent lists of one type
|
||||||
|
merge.
|
||||||
|
- Rejected in the survey: `~sub~` and `^sup^`, underline and colour spellings, raw HTML
|
||||||
|
(`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family, footnotes, definition
|
||||||
|
lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states past `[x]`/`[ ]`,
|
||||||
|
and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
|
||||||
|
- [ ] **10a — The reduction.** `adfToPlainMarkdown`'s ADF→ADF reduction, tests first, a test per
|
||||||
|
row above.
|
||||||
|
- [ ] **10b — The lift.** `plainMarkdownToAdf`'s ADF→ADF lift, tests first, a test per row it reads,
|
||||||
|
other tools' spellings included; the editor's default highlight colour looked up and cited.
|
||||||
|
- [ ] **10c — The exports.** `adfToPlainMarkdown` and `plainMarkdownToAdf` exported with their README
|
||||||
|
sections, and two properties over 4.2's generators: writing refuses only the guard's codes,
|
||||||
|
and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes
|
||||||
|
again byte for byte. AGENTS.md §1 records the pair as composed around the lossless one.
|
||||||
|
- [x] **11 — Atlassian's ADF schema as the tables' truth.**
|
||||||
|
- [x] **11a — The vendored schema.**
|
||||||
|
- [x] **11b — The gate.**
|
||||||
|
- [ ] **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:`. Leaf vs container is decided by
|
||||||
|
the node's content model rather than syntax — the `::`/`:::` split goes, 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. No
|
||||||
|
`ConvertErrorCode` is added, removed or renamed, and 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/`), the prose reader over `spec/flavour.md`,
|
||||||
|
and the README's examples.
|
||||||
|
**Settled** (the maintainer, 2026-09-13):
|
||||||
|
- A line opening `!adf:name` is a block line when a space or the line's end follows the name,
|
||||||
|
and a paragraph when `[` or `{` does. Claiming stays syntactic and structure comes from the
|
||||||
|
tables: an unknown name is `unknown-directive-name` at the opener, whatever follows it.
|
||||||
|
- An unescaped `!adf:` claims on its own anywhere inline: one completing no directive is
|
||||||
|
`malformed-directive`, the emitter escapes every literal `!adf:`, and `!adf:hardBreak{}`
|
||||||
|
keeps its braces. Block and inline share the one `\!adf:` escape hint.
|
||||||
|
- A closer names the innermost open container, crosses no list-item or blockquote edge,
|
||||||
|
indents as a fence does and carries nothing after the name; anything else is
|
||||||
|
`malformed-directive`.
|
||||||
|
- A node holding no content whose content model takes some is an empty opener–closer pair,
|
||||||
|
never a leaf.
|
||||||
|
- A spelled node's content model is frozen with its spelling: changing it is MAJOR (§8).
|
||||||
|
- The colon spellings are dropped, not refused: `0.1.0` markdown reads back as prose, `adf`
|
||||||
|
is no longer a reserved language, and `MIGRATION.md` tells a consumer to convert stored
|
||||||
|
markdown through `0.1.0`'s parser and `0.2.0`'s emitter.
|
||||||
|
- Inputs moving between codes ride the break: a leaf given a body, a container missing its
|
||||||
|
closer and `listBreak` with a body are `malformed-directive`, and an empty inline-body
|
||||||
|
container parses.
|
||||||
|
- Split by construct, each sub-item both directions: 55 of 78 round-trip fixtures feed both
|
||||||
|
the emit and the read-back test, so an emit-only chunk cannot land green.
|
||||||
|
- [ ] **12a — The spec and the decision.** `spec/flavour.md` rewritten to the `!adf:` grammar and
|
||||||
|
the settled answers above, no colon directive form left in it; AGENTS.md §4's directive
|
||||||
|
bullet and prior-art line, and §8's escape hints and `::adf`/`::listBreak` examples, name
|
||||||
|
the new forms, §8 gaining the frozen content model.
|
||||||
|
- [ ] **12b — The inline form.** Inline nodes, directive marks, `text` and the inline carry
|
||||||
|
`!adf:carry{json=…}` spelled and read as `!adf:name[content]{attrs}`, with the prefix claim
|
||||||
|
and its escape; the round-trip, normalization and `errors/` fixtures holding inline forms
|
||||||
|
re-spelled, and the gate green. The content slot of `emoji`, `mention` and `status` refuses a
|
||||||
|
text node carrying attributes as `unsupported-node-shape`, which it drops silently today (the
|
||||||
|
maintainer, 2026-09-14).
|
||||||
|
- [ ] **12c — The block form.** Openers and `!adf:/name` closers, leaf vs container by content
|
||||||
|
model, empty pairs, `listBreak` and the `carry` fence, spelled and read; the fence-length
|
||||||
|
rule and the corpus test's fence nesting check deleted; the remaining fixtures re-spelled
|
||||||
|
and `errors/` re-derived under the shifted codes, and the gate green.
|
||||||
|
- [ ] **12d — The README, `MIGRATION.md` and the sweep.** The README's examples and error tables
|
||||||
|
follow, `MIGRATION.md` linked from one README line; docs and fixtures swept for any stale
|
||||||
|
`::`/`:name` spelling.
|
||||||
|
- [ ] **13 — The schema's gap attributes (`0.2.0`).** Spell the attributes 11b pins as gaps, in
|
||||||
|
12's grammar, and empty the list.
|
||||||
|
**Settled** (the maintainer, 2026-09-13): a link `[text](url "title")` cannot hold takes the
|
||||||
|
directive mark `!adf:link[text]{attrs}` — one carrying `collection`, `id` or `occurrenceKey`,
|
||||||
|
or an `href` or `title` no CommonMark escape writes — and a directive link CommonMark could
|
||||||
|
spell is `unsupported-node-shape`. That leaves `unspellable-link` no cause, so it leaves
|
||||||
|
`ConvertErrorCode` in `0.2.0`, §8 recording the removal.
|
||||||
|
- [ ] **13a — `rule` and `layoutSection`.** `rule`'s `color`, `style` and `weight` and
|
||||||
|
`layoutSection`'s `columnRuleStyle` join their tables and `spec/flavour.md` bullets, with
|
||||||
|
round-trip fixtures; their gap entries go.
|
||||||
|
- [ ] **13b — The directive link.** `link` spelled as above in both directions, with a round-trip
|
||||||
|
fixture per trigger, `spec/flavour.md`'s Marks section following; `unspellable-link` removed
|
||||||
|
from the code list, its `errors/` fixtures and the CommonMark suite's `unspellable`
|
||||||
|
exceptions it cures re-derived, and the README's code table and its "not every document
|
||||||
|
converts back" guarantee following; the gap list is empty.
|
||||||
|
|
||||||
## The ADF inventory to cover
|
## The ADF inventory to cover
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user