Compare commits
13 Commits
d9056973a1
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a847dea0a | |||
| bcab476c87 | |||
| 197dee199b | |||
| 611336bb33 | |||
| 0523bb8182 | |||
| 4d13583d6e | |||
| 9e688f4b00 | |||
| edf11304e3 | |||
| e9a70ac8f6 | |||
| b58f51f846 | |||
| 7612feb75e | |||
| 67df1e2f4b | |||
| 0dfcc0f9ca |
@@ -9,6 +9,9 @@ jobs:
|
||||
- run: bash ci.sh
|
||||
|
||||
publish:
|
||||
concurrency:
|
||||
cancel-in-progress: false
|
||||
group: publish
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: gate
|
||||
runs-on: docker-host
|
||||
|
||||
@@ -131,10 +131,17 @@ descends, so a document reports its first error in document order. `not-an-adf-d
|
||||
the document's own path throughout: eight of the guard's nine branches read the document's own
|
||||
shape, and threading a path to the ninth — a malformed node anywhere in the tree — wants the
|
||||
manual stack §11's no-recursion rule forces, whose empty half no input reaches. The message names
|
||||
the violation instead. Depth is not one of the nine: the guard runs a second time unbounded, so an
|
||||
attribute value past 500 levels is `unsupported-nesting-depth` from the emitter as it already is
|
||||
from the parser, and both directions refuse the same value — the guard counts the levels an
|
||||
attribute holds, never the `attrs` object holding it.
|
||||
the violation instead. Depth is not one of the nine: `adfDocumentFault` returns the code with the
|
||||
message, so an attribute value past 500 levels is `unsupported-nesting-depth` from the emitter as
|
||||
it already is from the parser, both directions refusing the same value. A node's attribute is
|
||||
counted from the value itself, never from the `attrs` object holding it; a mark's is counted three
|
||||
levels in, because the block directive spells the whole mark set as one JSON attribute and the
|
||||
parser reads the value at the bottom of array, mark and `attrs`. `isAdfDocument` is true for a depth fault:
|
||||
a deep document is a document, as the 2000-level blocks and the 600-deep marks the guard already
|
||||
waves through are, and depth is the walks' answer rather than the shape's. A non-finite number
|
||||
stays parted where depth is joined: the parse says `unsupported-node-shape` because the markdown is
|
||||
at fault, the emit `not-an-adf-document` because the input is, and unlike depth nothing round-trips
|
||||
inconsistently between them.
|
||||
|
||||
`position` is the parse side's alone: an emitter reads no source, so an emit error carries `path`
|
||||
and nothing more. It is `{ line, offset }` at the start of the line the block holding the refusal
|
||||
@@ -156,6 +163,13 @@ wide `Result<T>`, since half their refusals come from an emit stage that read no
|
||||
deliberate semver judgment. `publish.sh` is that job, and `private: true` stops it before it
|
||||
reads the token, so the pipeline is live and silent until the maintainer's first bump drops the
|
||||
field.
|
||||
- The publish and the tag each observe their own end state — the version on npm, the tag on the
|
||||
remote — and neither gates the other, so a run that dies between them converges on the next push
|
||||
to `main` rather than leaving npm ahead of the tags. An unanswered registry reads the same as an
|
||||
unpublished version, which npm's own duplicate rejection is what catches. The job rebuilds rather
|
||||
than taking the gate's `dist`: the lockfile is committed, the image is patch-pinned and `tsc` is
|
||||
deterministic, so the two builds agree, and promoting an artifact would make the release path
|
||||
depend on a store that the gate would then have to keep.
|
||||
- Renovate watches devDependencies, Docker pins and action tags; automerges everything on green CI.
|
||||
- Docker images pin the full patch version (`node:24.19.0-alpine3.24`, never `node:24`), as
|
||||
specific as the publisher tags: `oven/bun:1.4.0-alpine` pins Bun's patch and leaves the base
|
||||
@@ -175,10 +189,23 @@ emphasis matching leans on can disagree. Both refuse a run matching no test, so
|
||||
vacuous-green guard, and a test may reach only for what all three `node:` shims carry — the price
|
||||
of proving those engines over the corpus rather than over a smoke import.
|
||||
|
||||
The gate then builds and runs `package-tests/` against what it built, reached by the package's own
|
||||
name so `exports` answers: `consumer.ts` typechecks the emitted `.d.ts` from outside
|
||||
`tsconfig.build.json`, since declaration emit leaves `.ts` specifiers a consumer's resolver must
|
||||
map itself, and `node-floor.js` round-trips under a Node pinned to `engines.node`'s floor.
|
||||
The gate then packs the build and installs the tarball under `package-tests/`, so `files`,
|
||||
`exports` and `types` are proved on the artifact that ships rather than on the source tree a
|
||||
self-reference would resolve against. `consumer.ts` typechecks the emitted `.d.ts` from outside
|
||||
`tsconfig.build.json` — declaration emit leaves the `.ts` specifiers
|
||||
`rewriteRelativeImportExtensions` rewrites in the JavaScript, and this is what says a consumer's
|
||||
resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` stays unproven.
|
||||
`node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor.
|
||||
|
||||
A fourth engine reads the build rather than the source: a headless Firefox loads `dist/index.js`
|
||||
over HTTP and converts the whole corpus, which is §6's browser half and the only SpiderMonkey
|
||||
there is — the gate's other three engines are two V8s and a JavaScriptCore that is not Safari's.
|
||||
A WebDriver session is what carries a verdict back out, the driver and the page's server sharing
|
||||
one network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such
|
||||
channel, and loading `dist/index.js` in a globals-stripped realm buys one by not running a browser.
|
||||
The leg re-checks the conversions and nothing else — each fixture's emitted markdown, its parsed
|
||||
document, its error code — leaving the corpus's pairing, uniqueness, source positions and
|
||||
byte-level equality to the Node suite that owns them.
|
||||
|
||||
The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and
|
||||
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
|
||||
|
||||
@@ -148,4 +148,5 @@ Personas, never named consumers (AGENTS.md §7):
|
||||
|
||||
ESM only, no runtime dependencies, public npmjs. Built JavaScript with `.d.ts` beside it.
|
||||
Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node,
|
||||
Deno and Bun. Contract: `AGENTS.md` §5–6.
|
||||
Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
|
||||
Contract: `AGENTS.md` §5–6.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
try {
|
||||
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
|
||||
window.convertCorpus = (corpus) => ({
|
||||
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
|
||||
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
|
||||
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
|
||||
const adf = JSON.parse(json)
|
||||
return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: markdownToAdf(markdown) }
|
||||
}),
|
||||
})
|
||||
} catch (cause) {
|
||||
window.adfCodecFault = `/dist/index.js did not load: ${cause}`
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>@larvit/adf-codec in a browser</title>
|
||||
<script type="module" src="/browser-tests/convert-corpus.js"></script>
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { extname, join } from 'node:path'
|
||||
|
||||
const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
|
||||
const driver = 'http://127.0.0.1:4444'
|
||||
const repoRoot = join(import.meta.dirname, '..')
|
||||
const corpusRoot = join(repoRoot, 'corpus')
|
||||
const corpusEntries = readdirSync(corpusRoot, { encoding: 'utf8', recursive: true })
|
||||
|
||||
function checking(name, result, assertions) {
|
||||
assert.equal(result?.name, name, `the browser answered ${JSON.stringify(result?.name)} where ${name} was sent`)
|
||||
try {
|
||||
assertions()
|
||||
} catch (cause) {
|
||||
throw new Error(`${name} in the browser — ${cause.message}`, { cause })
|
||||
}
|
||||
}
|
||||
|
||||
async function command(method, path, body) {
|
||||
const response = await fetch(`${driver}${path}`, {
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
method,
|
||||
signal: AbortSignal.timeout(180_000),
|
||||
})
|
||||
const payload = await response.json()
|
||||
if (!response.ok) throw new Error(`webdriver ${method} ${path}: ${JSON.stringify(payload.value)}`)
|
||||
return payload.value
|
||||
}
|
||||
|
||||
function fileBody(file) {
|
||||
try {
|
||||
return readFileSync(file)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(name, extension) {
|
||||
return readFileSync(join(corpusRoot, `${name}${extension}`), 'utf8')
|
||||
}
|
||||
|
||||
function fixtureNames(kind, extension) {
|
||||
return corpusEntries
|
||||
.filter((name) => name.startsWith(`${kind}/`) && name.endsWith(extension))
|
||||
.map((name) => name.slice(0, -extension.length))
|
||||
.sort()
|
||||
}
|
||||
|
||||
function refusal(result) {
|
||||
return result.ok ? '' : `${result.error.code}: ${result.error.message}`
|
||||
}
|
||||
|
||||
async function whenDriverAnswers() {
|
||||
const deadline = Date.now() + 60_000
|
||||
for (;;) {
|
||||
const status = await command('GET', '/status').catch(() => undefined)
|
||||
if (status?.ready === true) return
|
||||
if (Date.now() > deadline) throw new Error(`no WebDriver answered ${driver}/status within 60s`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
}
|
||||
|
||||
const corpus = {
|
||||
errors: fixtureNames('errors', '.md').map((name) => ({ markdown: fixture(name, '.md'), name })),
|
||||
normalization: fixtureNames('normalization', '.md').map((name) => ({ markdown: fixture(name, '.md'), name })),
|
||||
roundTrip: fixtureNames('round-trip', '.json').map((name) => ({ json: fixture(name, '.json'), markdown: fixture(name, '.md'), name })),
|
||||
}
|
||||
assert.deepEqual(
|
||||
readdirSync(corpusRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort(),
|
||||
['errors', 'normalization', 'round-trip'],
|
||||
'a corpus kind the browser leg does not convert',
|
||||
)
|
||||
for (const [kind, fixtures] of Object.entries(corpus)) assert.ok(fixtures.length > 0, `the browser leg found no ${kind} fixture to convert`)
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
const file = join(repoRoot, new URL(request.url ?? '/', 'http://127.0.0.1').pathname)
|
||||
const body = file.startsWith(repoRoot) ? fileBody(file) : undefined
|
||||
if (body === undefined) response.writeHead(404).end()
|
||||
else response.writeHead(200, { 'content-type': contentTypes[extname(file)] ?? 'application/octet-stream' }).end(body)
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
|
||||
await whenDriverAnswers()
|
||||
const session = await command('POST', '/session', {
|
||||
capabilities: { alwaysMatch: { browserName: 'firefox', 'moz:firefoxOptions': { args: ['-headless'] }, timeouts: { pageLoad: 60_000, script: 120_000 } } },
|
||||
})
|
||||
await command('POST', `/session/${session.sessionId}/url`, { url: `http://127.0.0.1:${server.address().port}/browser-tests/page.html` })
|
||||
const results = await command('POST', `/session/${session.sessionId}/execute/sync`, {
|
||||
args: [corpus],
|
||||
script: 'if (!window.convertCorpus) throw new Error(window.adfCodecFault ?? "the page defined no convertCorpus"); return window.convertCorpus(arguments[0])',
|
||||
})
|
||||
server.close()
|
||||
|
||||
for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) {
|
||||
const result = results.roundTrip[index]
|
||||
checking(name, result, () => {
|
||||
assert.ok(result.isDocument, `${name}.json is no ADF document`)
|
||||
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
|
||||
assert.equal(result.emitted.value, markdown)
|
||||
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
|
||||
assert.deepEqual(result.parsed.value, JSON.parse(json))
|
||||
})
|
||||
}
|
||||
|
||||
for (const [index, { name }] of corpus.normalization.entries()) {
|
||||
const result = results.normalization[index]
|
||||
checking(name, result, () => {
|
||||
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
|
||||
assert.deepEqual(result.parsed.value, JSON.parse(fixture(name, '.json')))
|
||||
})
|
||||
}
|
||||
|
||||
for (const [index, { name }] of corpus.errors.entries()) {
|
||||
const result = results.errors[index]
|
||||
checking(name, result, () => {
|
||||
assert.ok(!result.parsed.ok, `it was not refused, it built ${JSON.stringify(result.parsed.value)}`)
|
||||
assert.equal(result.parsed.error.code, fixture(name, '.error').trimEnd())
|
||||
})
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Firefox ${session.capabilities.browserVersion} converted ${corpus.roundTrip.length} round-trip, ${corpus.normalization.length} normalization and ${corpus.errors.length} error fixtures`,
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
source ./docker-images.sh
|
||||
source ./docker-runner.sh
|
||||
|
||||
in_image "$node_image" npm ci
|
||||
in_image "$node_image" npm run typecheck
|
||||
@@ -20,5 +20,11 @@ in_image "$deno_image" deno test --allow-read --no-check src/
|
||||
in_image "$bun_image" bun test src/
|
||||
|
||||
in_image "$node_image" npm run build
|
||||
in_image "$node_image" sh -c 'set -e
|
||||
rm -rf package-tests/node_modules
|
||||
npm pack --pack-destination /tmp >/dev/null
|
||||
npm install --no-audit --no-fund --no-package-lock --no-save --offline --prefix package-tests /tmp/*.tgz >/dev/null'
|
||||
in_image "$node_image" npx tsc -p package-tests
|
||||
in_image "$floor_image" node package-tests/node-floor.js
|
||||
|
||||
with_firefox in_image "$node_image" node browser-tests/run.js
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
bun_image=oven/bun:1.4.0-alpine
|
||||
deno_image=denoland/deno:2.9.6
|
||||
floor_image=node:18.20.8-alpine3.21
|
||||
node_image=node:24.19.0-alpine3.24
|
||||
|
||||
in_image() {
|
||||
local image=$1 entrypoint=$2
|
||||
shift 2
|
||||
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e NPM_TOKEN -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
bun_image=oven/bun:1.4.0-alpine
|
||||
deno_image=denoland/deno:2.9.6
|
||||
firefox_image=selenium/standalone-firefox:153.0.4
|
||||
floor_image=node:18.20.8-alpine3.21
|
||||
node_image=node:24.19.0-alpine3.24
|
||||
|
||||
in_image() {
|
||||
local image=$1 entrypoint=$2
|
||||
shift 2
|
||||
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
|
||||
}
|
||||
|
||||
with_firefox() {
|
||||
local container in_image_network status=0
|
||||
container=$(docker run -d --rm "$firefox_image")
|
||||
# The id is baked in: the trap fires after this function's locals are gone.
|
||||
trap "docker rm -f $container >/dev/null 2>&1" EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
in_image_network="container:$container"
|
||||
"$@" || status=$?
|
||||
return $status
|
||||
}
|
||||
Generated
+3
-3
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "@larvit/adf-codec",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larvit/adf-codec",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "adf-codec-package-tests",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "@larvit/adf-codec",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Lossless conversion between Atlassian Document Format, extended markdown and HTML",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -18,6 +17,7 @@
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
||||
+21
-12
@@ -1,28 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
source ./docker-images.sh
|
||||
source ./docker-runner.sh
|
||||
|
||||
read_field() {
|
||||
in_image "$node_image" npm pkg get "$1" | tr -d '"\r'
|
||||
}
|
||||
|
||||
if [ "$(read_field private)" = 'true' ]; then
|
||||
published_version() {
|
||||
in_image "$node_image" npm view "$1@$2" version 2>/dev/null || true
|
||||
}
|
||||
|
||||
private=$(read_field private)
|
||||
if [ "$private" = 'true' ]; then
|
||||
echo 'package.json is private — the maintainer removes that in the bump that first publishes'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
name=$(read_field name)
|
||||
version=$(read_field version)
|
||||
published=$(in_image "$node_image" npm view "$name@latest" version 2>/dev/null || true)
|
||||
if [ "$version" = "$published" ]; then
|
||||
echo "npm holds $name $version already — no bump, no deploy"
|
||||
exit 0
|
||||
published=$(published_version "$name" "$version")
|
||||
tagged=$(git ls-remote --tags origin "v$version")
|
||||
|
||||
# Both steps observe their own end state, so a partial run converges on the next push to main.
|
||||
if [ -z "$published" ]; then
|
||||
: "${NPM_TOKEN:?the publish needs NPM_TOKEN}"
|
||||
in_image "$node_image" npm ci
|
||||
in_image "$node_image" npm run build
|
||||
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e NPM_TOKEN -v "$PWD:/app" -w /app --entrypoint sh "$node_image" -c \
|
||||
'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public'
|
||||
fi
|
||||
|
||||
: "${NPM_TOKEN:?the publish needs NPM_TOKEN}"
|
||||
in_image "$node_image" npm ci
|
||||
in_image "$node_image" npm run build
|
||||
in_image "$node_image" sh -c 'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public'
|
||||
git tag "v$version"
|
||||
git push origin "v$version"
|
||||
if [ -z "$tagged" ]; then
|
||||
git tag "v$version"
|
||||
git push origin "v$version"
|
||||
fi
|
||||
|
||||
+13
-4
@@ -7,7 +7,7 @@
|
||||
"datasourceTemplate": "docker",
|
||||
"depNameTemplate": "denoland/deno",
|
||||
"description": "Pin the Deno image the gate runs",
|
||||
"managerFilePatterns": ["docker-images.sh"],
|
||||
"managerFilePatterns": ["docker-runner.sh"],
|
||||
"matchStrings": ["denoland/deno:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"versioningTemplate": "docker"
|
||||
},
|
||||
@@ -16,7 +16,7 @@
|
||||
"datasourceTemplate": "docker",
|
||||
"depNameTemplate": "node",
|
||||
"description": "Pin the node image the gate runs",
|
||||
"managerFilePatterns": ["docker-images.sh"],
|
||||
"managerFilePatterns": ["docker-runner.sh"],
|
||||
"matchStrings": ["node_image=node:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"versioningTemplate": "docker"
|
||||
},
|
||||
@@ -25,7 +25,7 @@
|
||||
"datasourceTemplate": "docker",
|
||||
"depNameTemplate": "node-floor",
|
||||
"description": "Pin the node image proving engines.node, held to that major",
|
||||
"managerFilePatterns": ["docker-images.sh"],
|
||||
"managerFilePatterns": ["docker-runner.sh"],
|
||||
"matchStrings": ["floor_image=node:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"packageNameTemplate": "node",
|
||||
"versioningTemplate": "docker"
|
||||
@@ -35,7 +35,7 @@
|
||||
"datasourceTemplate": "docker",
|
||||
"depNameTemplate": "oven/bun",
|
||||
"description": "Pin the Bun image the gate runs",
|
||||
"managerFilePatterns": ["docker-images.sh"],
|
||||
"managerFilePatterns": ["docker-runner.sh"],
|
||||
"matchStrings": ["oven/bun:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"versioningTemplate": "docker"
|
||||
},
|
||||
@@ -47,6 +47,15 @@
|
||||
"managerFilePatterns": [".gitea/workflows/renovate.yml"],
|
||||
"matchStrings": ["renovate/renovate:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"versioningTemplate": "docker"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"datasourceTemplate": "docker",
|
||||
"depNameTemplate": "selenium/standalone-firefox",
|
||||
"description": "Pin the Firefox image the gate's browser leg runs",
|
||||
"managerFilePatterns": ["docker-runner.sh"],
|
||||
"matchStrings": ["selenium/standalone-firefox:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"versioningTemplate": "docker"
|
||||
}
|
||||
],
|
||||
"extends": ["config:recommended"],
|
||||
|
||||
@@ -6,7 +6,11 @@ import { adfDocumentFault, isAdfDocument } from './document.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
|
||||
function fault(value: unknown): string {
|
||||
return adfDocumentFault(value) ?? 'accepted'
|
||||
return adfDocumentFault(value)?.message ?? 'accepted'
|
||||
}
|
||||
|
||||
function faultCode(value: unknown): string {
|
||||
return adfDocumentFault(value)?.code ?? 'accepted'
|
||||
}
|
||||
|
||||
function nested(levels: number): JsonValue {
|
||||
@@ -56,13 +60,17 @@ test('rejects a node whose shape ProseMirror JSON cannot hold', () => {
|
||||
assert.equal(isAdfDocument({ content: [{ attrs: [], type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
||||
})
|
||||
|
||||
test('holds an attribute value to the levels the parser reads one at, the attrs object costing none', () => {
|
||||
assert.equal(isAdfDocument(withAttribute(nested(largestNesting))), true)
|
||||
assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), false)
|
||||
assert.equal(adfDocumentFault(withAttribute(nested(largestNesting + 1)), Number.POSITIVE_INFINITY), undefined)
|
||||
const marked = { content: [{ marks: [{ attrs: { a: nested(largestNesting + 1) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }
|
||||
assert.equal(isAdfDocument(marked), false)
|
||||
assert.equal(adfDocumentFault(marked, Number.POSITIVE_INFINITY), undefined)
|
||||
test('names the attribute nesting past the levels the parser reads one at, and still calls the value a document', () => {
|
||||
const deeper = (key: string, type: string, levels: number = largestNesting): string =>
|
||||
`the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
|
||||
assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted')
|
||||
assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph'))
|
||||
assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth')
|
||||
assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true)
|
||||
const marked = (levels: number): unknown => ({ content: [{ marks: [{ attrs: { a: nested(levels) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 })
|
||||
assert.equal(fault(marked(largestNesting - 3)), 'accepted')
|
||||
assert.equal(fault(marked(largestNesting - 2)), deeper('a', 'link', largestNesting - 3))
|
||||
assert.equal(isAdfDocument(marked(largestNesting - 2)), true)
|
||||
})
|
||||
|
||||
test('accepts the JSON values an attribute may hold', () => {
|
||||
|
||||
+63
-21
@@ -1,4 +1,5 @@
|
||||
import { isJsonValue, type JsonValue } from '../json-value.ts'
|
||||
import type { ConvertFault } from '../result.ts'
|
||||
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
|
||||
export type AdfAttributes = { [key: string]: JsonValue }
|
||||
@@ -22,23 +23,32 @@ export type AdfDocument = {
|
||||
version: number
|
||||
}
|
||||
|
||||
// A block directive spells the whole mark set as one JSON attribute, so a mark's value sits three levels inside it.
|
||||
const markAttributeNesting = largestNesting - 3
|
||||
|
||||
const documentKeys = ['content', 'type', 'version']
|
||||
const markKeys = ['attrs', 'type']
|
||||
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
|
||||
|
||||
export function adfDocumentFault(value: unknown, levels: number = largestNesting): string | undefined {
|
||||
if (!isRecord(value)) return `an ADF document is an object: found ${describe(value)}`
|
||||
export function adfDocumentFault(value: unknown): ConvertFault | undefined {
|
||||
if (!isRecord(value)) return notADocument(`an ADF document is an object: found ${describe(value)}`)
|
||||
const extra = extraKey(value, documentKeys)
|
||||
if (extra !== undefined) return `an ADF document holds content, type and version alone: found the key ${extra}`
|
||||
if (!('type' in value)) return 'an ADF document holds type "doc": found no type field'
|
||||
if (value['type'] !== 'doc') return `an ADF document holds type "doc": found ${describe(value['type'])}`
|
||||
if (!('version' in value)) return 'an ADF document holds a version number: found no version field'
|
||||
if (extra !== undefined) return notADocument(`an ADF document holds content, type and version alone: found the key ${extra}`)
|
||||
if (!('type' in value)) return notADocument('an ADF document holds type "doc": found no type field')
|
||||
if (value['type'] !== 'doc') return notADocument(`an ADF document holds type "doc": found ${describe(value['type'])}`)
|
||||
if (!('version' in value)) return notADocument('an ADF document holds a version number: found no version field')
|
||||
const version = value['version']
|
||||
if (typeof version !== 'number' || !Number.isFinite(version)) return `an ADF document holds a version number: found ${describe(version)}`
|
||||
if (typeof version !== 'number' || !Number.isFinite(version)) return notADocument(`an ADF document holds a version number: found ${describe(version)}`)
|
||||
if (!('content' in value)) return undefined
|
||||
const content = value['content']
|
||||
if (!Array.isArray(content)) return `an ADF document's content is an array: found ${describe(content)}`
|
||||
return isNodeArray(content, levels) ? undefined : "an ADF document's content holds ADF nodes: one of them is not"
|
||||
const held: unknown = value['content']
|
||||
if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`)
|
||||
const content: readonly unknown[] = held
|
||||
if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not")
|
||||
return nestingFault(content)
|
||||
}
|
||||
|
||||
export function attributeNestingMessage(key: string, type: string, levels: number = largestNesting): string {
|
||||
return `the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
|
||||
}
|
||||
|
||||
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
|
||||
@@ -46,28 +56,30 @@ export function carriesOnly(node: AdfNode, attributes: readonly string[]): boole
|
||||
return holdsOnly(node.attrs ?? {}, attributes)
|
||||
}
|
||||
|
||||
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
|
||||
export function isAdfDocument(value: unknown): value is AdfDocument {
|
||||
return adfDocumentFault(value) === undefined
|
||||
const fault = adfDocumentFault(value)
|
||||
return fault === undefined || fault.code === 'unsupported-nesting-depth'
|
||||
}
|
||||
|
||||
export function isAdfNode(value: unknown): value is AdfNode {
|
||||
return isNodeArray([value], largestNesting)
|
||||
return isNodeArray([value])
|
||||
}
|
||||
|
||||
export function isAdfMark(value: unknown, levels: number = largestNesting): value is AdfMark {
|
||||
export function isAdfMark(value: unknown): value is AdfMark {
|
||||
if (!isRecord(value) || !holdsOnly(value, markKeys)) return false
|
||||
if (typeof value['type'] !== 'string') return false
|
||||
return !('attrs' in value) || isAttributes(value['attrs'], levels)
|
||||
return !('attrs' in value) || isAttributes(value['attrs'])
|
||||
}
|
||||
|
||||
function isNodeArray(value: readonly unknown[], levels: number): boolean {
|
||||
function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
|
||||
const pending: unknown[] = [...value]
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop()
|
||||
if (!isRecord(node) || !holdsOnly(node, nodeKeys)) return false
|
||||
if (typeof node['type'] !== 'string') return false
|
||||
if ('attrs' in node && !isAttributes(node['attrs'], levels)) return false
|
||||
if ('marks' in node && !isArrayOf(node['marks'], (mark): mark is AdfMark => isAdfMark(mark, levels))) return false
|
||||
if ('attrs' in node && !isAttributes(node['attrs'])) return false
|
||||
if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) return false
|
||||
if ('text' in node && typeof node['text'] !== 'string') return false
|
||||
if ('content' in node) {
|
||||
const content = node['content']
|
||||
@@ -78,19 +90,49 @@ function isNodeArray(value: readonly unknown[], levels: number): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
|
||||
const pending: AdfNode[] = [...nodes]
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop()
|
||||
if (node === undefined) continue
|
||||
const fault = attributesFault(node.attrs, node.type) ?? marksFault(node.marks)
|
||||
if (fault !== undefined) return fault
|
||||
pending.push(...(node.content ?? []))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function marksFault(marks: readonly AdfMark[] | undefined): ConvertFault | undefined {
|
||||
for (const mark of marks ?? []) {
|
||||
const fault = attributesFault(mark.attrs, mark.type, markAttributeNesting)
|
||||
if (fault !== undefined) return fault
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function attributesFault(attrs: AdfAttributes | undefined, type: string, levels: number = largestNesting): ConvertFault | undefined {
|
||||
for (const [key, value] of Object.entries(attrs ?? {})) {
|
||||
if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isArrayOf<T>(value: unknown, guard: (item: unknown) => item is T): value is T[] {
|
||||
return Array.isArray(value) && [...value].every(guard)
|
||||
}
|
||||
|
||||
// Per value, so an attribute reaches the same 500 levels the parser reads one at (AGENTS.md §11).
|
||||
function isAttributes(value: unknown, levels: number): value is AdfAttributes {
|
||||
return isRecord(value) && Object.values(value).every((held) => isJsonValue(held, levels))
|
||||
function isAttributes(value: unknown): value is AdfAttributes {
|
||||
return isRecord(value) && isJsonValue(value)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function notADocument(message: string): ConvertFault {
|
||||
return { code: 'not-an-adf-document', message }
|
||||
}
|
||||
|
||||
function describe(value: unknown): string {
|
||||
if (typeof value === 'string') return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value)
|
||||
if (typeof value === 'function') return 'a function'
|
||||
|
||||
+18
-8
@@ -2,22 +2,32 @@ import { largestNesting } from './nesting.ts'
|
||||
|
||||
export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue }
|
||||
|
||||
export function isJsonValue(value: unknown, levels: number = largestNesting): value is JsonValue {
|
||||
const pending: { depth: number; item: unknown }[] = [{ depth: 0, item: value }]
|
||||
export function isJsonValue(value: unknown): value is JsonValue {
|
||||
const pending: unknown[] = [value]
|
||||
while (pending.length > 0) {
|
||||
const entry = pending.pop()
|
||||
if (entry === undefined) continue
|
||||
const { depth, item } = entry
|
||||
if (depth > levels) return false
|
||||
const item = pending.pop()
|
||||
if (item === null || typeof item === 'boolean' || typeof item === 'string') continue
|
||||
if (typeof item === 'number') {
|
||||
if (!Number.isFinite(item)) return false
|
||||
continue
|
||||
}
|
||||
// A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them.
|
||||
if (Array.isArray(item)) for (const child of [...item]) pending.push({ depth: depth + 1, item: child })
|
||||
else if (typeof item === 'object') for (const child of Object.values(item)) pending.push({ depth: depth + 1, item: child })
|
||||
if (Array.isArray(item)) for (const child of [...item]) pending.push(child)
|
||||
else if (typeof item === 'object') for (const child of Object.values(item)) pending.push(child)
|
||||
else return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function overNested(value: JsonValue, levels: number = largestNesting): boolean {
|
||||
const pending: { depth: number; item: JsonValue }[] = [{ depth: 0, item: value }]
|
||||
while (pending.length > 0) {
|
||||
const entry = pending.pop()
|
||||
if (entry === undefined) continue
|
||||
const { depth, item } = entry
|
||||
if (depth > levels) return true
|
||||
if (Array.isArray(item)) for (const child of item) pending.push({ depth: depth + 1, item: child })
|
||||
else if (item !== null && typeof item === 'object') for (const child of Object.values(item)) pending.push({ depth: depth + 1, item: child })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ import type { ConvertFault } from '../result.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { backslashEscape, claimsDirectiveLine } from './commonmark-grammar.ts'
|
||||
import { backtickRun, closingBacktickRun } from './backtick-runs.ts'
|
||||
import { isJsonValue } from '../json-value.ts'
|
||||
import { isJsonValue, overNested } from '../json-value.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
import { runLength } from './emphasis-matching.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
|
||||
export type AttributeReading = { refusal: 'kind' | 'nesting'; value?: undefined } | { refusal?: undefined; value: VocabularyValue }
|
||||
|
||||
export type DirectiveValue = { decoded: string; spelling: string }
|
||||
|
||||
export type DirectiveAttributes = ReadonlyMap<string, DirectiveValue>
|
||||
@@ -45,18 +47,13 @@ const orderFault = 'the {attrs} keys read in alphabetical order'
|
||||
const pairFault = 'an attribute reads key=value, the value bare or double-quoted: this one does not'
|
||||
const shapeFault = `a directive line reads a name, one bare argument and {attrs}, one space apart: this one does not; ${directiveLineEscape}`
|
||||
|
||||
export function attributeNestingFault(text: string, kind: AttributeKind, key: string, type: string): ConvertFault | undefined {
|
||||
if (kind !== 'json' || parseJson(text, Number.POSITIVE_INFINITY) === undefined) return undefined
|
||||
return { code: 'unsupported-nesting-depth', message: `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels the parser carries` }
|
||||
}
|
||||
|
||||
export function attributeValue(text: string, kind: AttributeKind): VocabularyValue | undefined {
|
||||
if (kind === 'string') return { kind, value: text }
|
||||
if (kind === 'boolean') return text === 'true' || text === 'false' ? { kind, value: text === 'true' } : undefined
|
||||
export function attributeValue(text: string, kind: AttributeKind): AttributeReading {
|
||||
if (kind === 'string') return { value: { kind, value: text } }
|
||||
if (kind === 'boolean') return text === 'true' || text === 'false' ? { value: { kind, value: text === 'true' } } : { refusal: 'kind' }
|
||||
const parsed = parseJson(text)
|
||||
if (parsed === undefined) return undefined
|
||||
if (kind === 'json') return { kind, value: parsed }
|
||||
return typeof parsed === 'number' ? { kind, value: parsed } : undefined
|
||||
if (parsed === undefined) return { refusal: 'kind' }
|
||||
if (kind === 'number') return typeof parsed === 'number' ? { value: { kind, value: parsed } } : { refusal: 'kind' }
|
||||
return overNested(parsed) ? { refusal: 'nesting' } : { value: { kind, value: parsed } }
|
||||
}
|
||||
|
||||
export function isBareToken(text: string): boolean {
|
||||
@@ -299,10 +296,10 @@ function readQuotedValue(text: string, index: number): Read<{ end: number; value
|
||||
return { value: { end: cursor + 1, value: { decoded: parsed, spelling } } }
|
||||
}
|
||||
|
||||
function parseJson(raw: string, levels: number = largestNesting): JsonValue | undefined {
|
||||
function parseJson(raw: string): JsonValue | undefined {
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw)
|
||||
return isJsonValue(value, levels) ? value : undefined
|
||||
return isJsonValue(value) ? value : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -336,18 +336,32 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
|
||||
let attrs: AdfMark['attrs'] = { depth: 'x' }
|
||||
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
|
||||
const deeper = `unsupported-nesting-depth: an attribute value nests deeper than the ${largestNesting} levels the emitter carries`
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper)
|
||||
const card = (levels: number): AdfNode => {
|
||||
let data: JsonValue = 1
|
||||
for (let level = 0; level < levels; level += 1) data = [data]
|
||||
return { attrs: { data, url: 'https://example.com/a' }, type: 'inlineCard' }
|
||||
const deeper = (key: string, type: string, levels: number = largestNesting): string =>
|
||||
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
|
||||
const nested = (levels: number): JsonValue => {
|
||||
let value: JsonValue = 1
|
||||
for (let level = 0; level < levels; level += 1) value = [value]
|
||||
return value
|
||||
}
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper)
|
||||
const card = (levels: number): AdfNode => ({ attrs: { data: nested(levels), url: 'https://example.com/a' }, type: 'inlineCard' })
|
||||
const marked = (levels: number): AdfNode => ({
|
||||
attrs: { panelType: 'info' },
|
||||
content: [paragraph({ text: 'x', type: 'text' })],
|
||||
marks: [{ attrs: { deep: nested(levels) }, type: 'em' }],
|
||||
type: 'panel',
|
||||
})
|
||||
const roundTrips = (node: AdfNode): void => {
|
||||
const spelled = adfToMarkdown(document(node))
|
||||
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
|
||||
assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(node) })
|
||||
}
|
||||
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3))
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard'))
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), [])
|
||||
const spelled = adfToMarkdown(document(paragraph(card(largestNesting))))
|
||||
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
|
||||
assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(paragraph(card(largestNesting))) })
|
||||
roundTrips(paragraph(card(largestNesting)))
|
||||
assert.equal(markdown(adfToMarkdown(document(marked(largestNesting - 2)))), deeper('deep', 'em', largestNesting - 3))
|
||||
roundTrips(marked(largestNesting - 3))
|
||||
})
|
||||
|
||||
test('escapes a literal delimiter that would merge with an emitted one', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { adfDocumentFault, carriesOnly } from '../../adf/document.ts'
|
||||
import { blockDirective } from '../../adf/block-directives.ts'
|
||||
import { carriedBlock } from '../opaque-carry.ts'
|
||||
import { emitInlineLine } from './inline-line.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { fencedCodeBlock } from '../backtick-runs.ts'
|
||||
import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
@@ -24,10 +24,7 @@ const largestListMarker = 999999999
|
||||
|
||||
export function adfToMarkdown(document: AdfDocument): Result<string> {
|
||||
const fault = adfDocumentFault(document)
|
||||
if (fault !== undefined && adfDocumentFault(document, Number.POSITIVE_INFINITY) === undefined) {
|
||||
return failure('unsupported-nesting-depth', `an attribute value nests deeper than the ${largestNesting} levels the emitter carries`, [])
|
||||
}
|
||||
if (fault !== undefined) return failure('not-an-adf-document', fault, [])
|
||||
if (fault !== undefined) return faulted(fault, [])
|
||||
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
||||
const blocks = emitBlocks(document.content ?? [], 'document', [], 0)
|
||||
if (!blocks.ok) return blocks
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
||||
import type { JsonSpelling } from '../canonical-json.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
|
||||
import { isAdfNode } from '../adf/document.ts'
|
||||
import { isJsonValue } from '../json-value.ts'
|
||||
import { isJsonValue, overNested } from '../json-value.ts'
|
||||
import { fencedCodeBlock } from './backtick-runs.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
||||
@@ -37,7 +37,7 @@ export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefine
|
||||
}
|
||||
|
||||
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<string> {
|
||||
if (!isJsonValue(node, levels)) {
|
||||
if (!isJsonValue(node) || overNested(node, levels)) {
|
||||
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
|
||||
}
|
||||
return success(serializeCanonicalJson(node, spelling))
|
||||
@@ -47,9 +47,8 @@ function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): R
|
||||
const parsed = parseJsonText(raw)
|
||||
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') }
|
||||
const { value } = parsed
|
||||
if (!isJsonValue(value, levels)) {
|
||||
// Unbounded, the same walk parts the two causes one `false` holds (AGENTS.md §8).
|
||||
if (!isJsonValue(value, Number.POSITIVE_INFINITY)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
|
||||
if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
|
||||
if (overNested(value, levels)) {
|
||||
return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } }
|
||||
}
|
||||
if (serializeCanonicalJson(value, spelling) !== raw) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { AdfAttributes } from '../../adf/document.ts'
|
||||
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
|
||||
import type { DirectiveAttributes } from '../directive-syntax.ts'
|
||||
import { attributeNestingFault, attributeValue, spellAttributeValue } from '../directive-syntax.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { attributeNestingMessage } from '../../adf/document.ts'
|
||||
import { attributeValue, spellAttributeValue } from '../directive-syntax.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
|
||||
export type Elsewhere = { key: string; slot: 'argument' | 'content' }
|
||||
|
||||
@@ -22,14 +23,11 @@ export function readVocabulary(
|
||||
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
|
||||
if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute: this one spells it`, path)
|
||||
const read = attributeValue(spelled.decoded, kind)
|
||||
if (read === undefined) {
|
||||
const deep = attributeNestingFault(spelled.decoded, kind, key, type)
|
||||
if (deep !== undefined) return faulted(deep, path)
|
||||
return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path)
|
||||
}
|
||||
const spelling = spellAttributeValue(read)
|
||||
if (read.refusal === 'nesting') return failure('unsupported-nesting-depth', attributeNestingMessage(key, type), path)
|
||||
if (read.value === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path)
|
||||
const spelling = spellAttributeValue(read.value)
|
||||
if (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path)
|
||||
attrs[key] = read.value
|
||||
attrs[key] = read.value.value
|
||||
}
|
||||
return success(attrs)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { BlockDirective } from '../../adf/block-directives.ts'
|
||||
import type { ConvertFault } from '../../result.ts'
|
||||
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
|
||||
import type { Elsewhere } from './directive-attributes.ts'
|
||||
import { attributeNestingFault, attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
|
||||
import { attributeNestingMessage } from '../../adf/document.ts'
|
||||
import { attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
|
||||
import { blockArgument } from '../block-directive-arguments.ts'
|
||||
import { blockDirective } from '../../adf/block-directives.ts'
|
||||
import { carryName } from '../opaque-carry.ts'
|
||||
@@ -94,9 +95,8 @@ function slotText(content: readonly AdfNode[]): string | undefined {
|
||||
|
||||
function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result<AdfMark[]> {
|
||||
const read = attributeValue(spelled.decoded, 'json')
|
||||
const deep = read === undefined ? attributeNestingFault(spelled.decoded, 'json', marksAttribute, type) : undefined
|
||||
if (deep !== undefined) return faulted(deep, path)
|
||||
const marks = read === undefined || spellAttributeValue(read) !== spelled.spelling ? undefined : readMarkValues(read.value)
|
||||
if (read.refusal === 'nesting') return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, type), path)
|
||||
const marks = read.value === undefined || spellAttributeValue(read.value) !== spelled.spelling ? undefined : readMarkValues(read.value.value)
|
||||
if (marks === undefined) {
|
||||
return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON: this one is not`, path)
|
||||
}
|
||||
|
||||
@@ -437,9 +437,11 @@ test('names the attribute a node holds no reading for', () => {
|
||||
|
||||
test('names the depth an attribute value nests past, never the kind the JSON reads as', () => {
|
||||
const nested = (levels: number): string => `${'['.repeat(levels)}1${']'.repeat(levels)}`
|
||||
const deeper = (key: string, type: string): string => `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels the parser carries`
|
||||
const deeper = (key: string, type: string): string =>
|
||||
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
|
||||
assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${nested(largestNesting + 1)}"}\n:::\n`)), deeper('colwidth', 'tableCell'))
|
||||
assert.equal(content(markdownToAdf(`::rule {marks="${nested(largestNesting + 1)}"}\n`)), deeper('marks', 'rule'))
|
||||
assert.equal(content(markdownToAdf(`::rule {marks="[{\\"attrs\\":{\\"deep\\":${nested(largestNesting - 2)}},\\"type\\":\\"em\\"}]"}\n`)), deeper('marks', 'rule'))
|
||||
assert.equal(content(markdownToAdf(`::media {width="${nested(largestNesting + 1)}"}\n`)), 'unsupported-node-shape: the width attribute of media is no number')
|
||||
})
|
||||
|
||||
|
||||
+25
-2
@@ -498,8 +498,11 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
|
||||
its own text: an attribute value past 500 levels was `unsupported-node-shape` on parse and
|
||||
`not-an-adf-document` on emit, the document guard counting the `attrs` object as a level the
|
||||
parser does not, so a value at exactly 500 parsed into a document the emitter then refused.
|
||||
The guard now holds each attribute value to 500 of its own and runs a second time unbounded,
|
||||
which parts depth from shape, and both directions answer with `unsupported-nesting-depth`.
|
||||
Depth left the shape predicates on both sides: `isJsonValue` structural and `overNested`
|
||||
beside it, `adfDocumentFault` returning the code with the message and `attributeValue` the
|
||||
reason it refused, so both directions answer with `unsupported-nesting-depth` naming the
|
||||
attribute, and `isAdfDocument` calls a deep document a document as it always did a deep
|
||||
block.
|
||||
`engines.node` gets its one-line proof too — the built entrypoint imported and round-tripped
|
||||
under a pinned Node 18 image, which cannot run the suite that type stripping wants 22+ for,
|
||||
but proves exactly what the field claims. Beside it, the emitted `.d.ts` typechecked from a
|
||||
@@ -509,3 +512,23 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
|
||||
canonical-fixpoint sentence claiming more than `0.1.0` keeps — 3e names three shapes that
|
||||
parse and then refuse — so it now says a parse succeeding is no promise of a way back, and
|
||||
names them.
|
||||
- [x] **5d — The browser leg.** §6's browser half is checkable on the emitted `dist/index.js` a
|
||||
browser can load — the compile gate names no host API, and a real page converting the corpus
|
||||
is the other half. Headless Firefox is that page, settling both at once: the browser proof,
|
||||
and the only SpiderMonkey there is, the gate's three engine legs being two V8s and a
|
||||
JavaScriptCore that is not Safari's. The mechanism is the decision this item opens with: a
|
||||
browser leg wants an image, a driver and a way to carry a verdict back out, none of which
|
||||
the gate's plain `docker run` per engine has. The answer is `with_firefox`, which runs the
|
||||
Firefox image beside the node one in a shared network namespace, so the page's server and
|
||||
the driver are each other's `127.0.0.1` and no user-defined network, container name or
|
||||
geckodriver `--allow-hosts` entry is wanted; its `EXIT INT TERM` trap bakes in the container
|
||||
id, since the `local` holding it is gone by the time the trap fires. `browser-tests/run.js`
|
||||
serves the repo, drives one `execute/sync` and asserts the results against the corpus with the
|
||||
Node-side `assert.deepEqual` the corpus runner uses, so the browser page holds no second copy
|
||||
of the comparison. The whole corpus fits: 118 fixtures in 8s warm against a 120s script
|
||||
timeout — no slice was worth choosing. A `try` around the dynamic import is what turns a
|
||||
broken build into SpiderMonkey's own message rather than an undefined global.
|
||||
**Settled** (the maintainer, 2026-09-04): `selenium/standalone-firefox` over the smaller
|
||||
`instrumentisto/geckodriver`, currency over size — the leg's whole worth is a real
|
||||
SpiderMonkey, which decays the moment the pin stops moving, and the smaller image was four
|
||||
Firefox majors behind with a publisher that may go quiet while Renovate stays silent.
|
||||
|
||||
@@ -5,7 +5,7 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h
|
||||
|
||||
## Milestones
|
||||
|
||||
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0`; 4b and 4c → `0.1.1`; 4, 3k → `0.2.0`;
|
||||
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`;
|
||||
6, 7 → `0.3.0`.
|
||||
The numbering is the order the work was planned in, not the order it ships.
|
||||
|
||||
@@ -95,7 +95,11 @@ The numbering is the order the work was planned in, not the order it ships.
|
||||
fallback. Memoizing `emitBlock` is the shortcut, and the node reference is the wrong key: a
|
||||
caller may hold one node object at two positions, where the cached depth and path are
|
||||
another node's. `0.1.0` ships with the retry in it, so a deep document is slow rather than
|
||||
wrong until the patch.
|
||||
wrong until the patch. `adfDocumentFault` is the second site to look at: `isNodeArray` reads
|
||||
every node and attribute value, then `nestingFault` reads them again, so the emit entry the
|
||||
export persona runs in bulk walks the document twice. Both walks are linear, so this is a
|
||||
constant factor rather than 4b's class change, and the parting is what gives depth its own
|
||||
code (§8) — measure before joining them back.
|
||||
- [ ] **4c — The scanning rule's remaining sites (`0.1.1`).** A trailing-anchored regex re-walks
|
||||
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
|
||||
@@ -108,9 +112,26 @@ 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
|
||||
guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents
|
||||
nobody typed.
|
||||
- [ ] **4d — What the gate says while it runs (`0.1.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
|
||||
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
|
||||
this one prints nothing, so name the leg and the image before each. The longest leg is the
|
||||
quietest: `test_output=$(… npm test 2>&1)` buffers the whole Node run to replay it after,
|
||||
because the zero-test guard greps the count — stream it and grep a copy (`tee`), rather than
|
||||
trading the output for the guard. And two legs are silenced outright, `npm pack` and the
|
||||
tarball install, whose `>/dev/null` predates the offline install that made them quick and
|
||||
quiet. `publish.sh` owes the same: today it says nothing between reading `private` and the
|
||||
registry answering, which is where its `npm ci` and rebuild sit — the seconds §9 accepts
|
||||
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
|
||||
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
|
||||
public (§6), create the `NPM_TOKEN` secret, and open the bump PR that sets `version` to
|
||||
`0.1.0` and drops `private: true`, the guard against any earlier publish. `0.1.0` is the
|
||||
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
|
||||
@@ -125,13 +146,7 @@ The numbering is the order the work was planned in, not the order it ships.
|
||||
- [x] **5b3 — The code list and the flavour's gaps.**
|
||||
- [x] **5b4 — The README's consumer surface.**
|
||||
- [x] **5c — The build and the release pipeline.**
|
||||
- [ ] **5d — The browser leg (`0.1.0`).** §6's browser half is checkable on the emitted
|
||||
`dist/index.js` a browser can load — the compile gate names no host API, and a real page
|
||||
converting the corpus is the other half. Headless Firefox is that page, settling both at
|
||||
once: the browser proof, and the only SpiderMonkey there is, the gate's three engine legs
|
||||
being two V8s and a JavaScriptCore that is not Safari's. The mechanism is the decision this
|
||||
item opens with: a browser leg wants an image, a driver and a way to carry a verdict back
|
||||
out, none of which the gate's plain `docker run` per engine has.
|
||||
- [x] **5d — The browser leg.**
|
||||
- [ ] **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.
|
||||
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /
|
||||
|
||||
Reference in New Issue
Block a user