Compare commits
1 Commits
ece1b029ee
...
d9056973a1
| Author | SHA1 | Date | |
|---|---|---|---|
| d9056973a1 |
@@ -7,3 +7,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- run: bash ci.sh
|
||||
|
||||
publish:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: gate
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: bash publish.sh
|
||||
|
||||
@@ -16,7 +16,9 @@ less silently destroys content an editor could not represent, in a document it d
|
||||
When losslessness and readability conflict, losslessness wins.
|
||||
|
||||
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
|
||||
back yields the library's canonical spelling, and that spelling round-trips byte-identically.
|
||||
back yields the library's canonical spelling, and that spelling round-trips byte-identically —
|
||||
where there is a way back. CommonMark spells link destinations the flavour has no escape for, so a
|
||||
parse succeeding does not imply a spellable document; `todo.md` 3k's exception list names those.
|
||||
|
||||
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
|
||||
merged, JSON number semantics, an empty attrs object, marks array or content array the absent
|
||||
@@ -62,8 +64,8 @@ they never reach a consumer.
|
||||
|
||||
- Runs on any ES2022 engine, not only Node — a browser as readily as a server. The shipped source
|
||||
is ECMAScript and nothing else: no host import, no host global, no DOM. `tsconfig.build.json` is
|
||||
that gate, typechecking the shipped files alone, so `node:fs`, `process` and an ES2024 method are
|
||||
compile errors here rather than a consumer's crash there. The standard is the line, never an
|
||||
that gate, typechecking and emitting the shipped files alone, so `node:fs`, `process` and an
|
||||
ES2024 method are compile errors here rather than a consumer's crash there. The standard is the line, never an
|
||||
engine list: one implementing it in part — Hermes is the live doubt, on §10's property escapes
|
||||
and on lookbehind — is out of scope rather than a bug. Node's test runner, the corpus reads and
|
||||
the build are the repo's own,
|
||||
@@ -129,7 +131,10 @@ 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.
|
||||
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.
|
||||
|
||||
`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
|
||||
@@ -148,7 +153,9 @@ wide `Result<T>`, since half their refusals come from an emit stage that read no
|
||||
|
||||
- `package.json` version on `main` is the source of truth. CI on `main`: tests green and version
|
||||
differs from npm → publish and tag `vX.Y.Z`. No bump, no deploy; the bump is each shipping PR's
|
||||
deliberate semver judgment.
|
||||
deliberate semver judgment. `publish.sh` is that job, and `private: true` stops it before it
|
||||
reads the token, so the pipeline is live and silent until the maintainer's first bump drops the
|
||||
field.
|
||||
- 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
|
||||
@@ -168,6 +175,11 @@ 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 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
|
||||
`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
|
||||
|
||||
@@ -101,7 +101,7 @@ emit refuses:
|
||||
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
|
||||
| `unspellable-link` | a link `href` or `title` holds what no canonical escape spells — a backslash, a newline, a control character, an entity reference, an angle bracket beside a space | percent-encode the destination (`%5C` for the backslash, `%26` for the `&` that opens the entity), or drop the title |
|
||||
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
|
||||
| `unsupported-nesting-depth` | blocks, marks or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
|
||||
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
|
||||
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take — or markdown writes as a directive a node the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body |
|
||||
|
||||
## The guarantees
|
||||
@@ -112,7 +112,9 @@ emit refuses:
|
||||
carve-outs — literal text matching directive, pipe-table or strikethrough syntax is claimed
|
||||
(escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as its own
|
||||
title-less paragraph; mid-text and titled images are error results. Converting back yields the
|
||||
library's canonical spelling, which round-trips byte-identically.
|
||||
library's canonical spelling, which round-trips byte-identically — where it converts back at
|
||||
all: a parse succeeding is no promise of that, so keep the source until the way back succeeds.
|
||||
`[a](/a\b)`, `<http://x?a=1&b=2>` and `[a](/x y)` read cleanly and then refuse.
|
||||
- Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a
|
||||
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
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
bun_image=oven/bun:1.4.0-alpine
|
||||
deno_image=denoland/deno:2.9.6
|
||||
node_image=node:24.19.0-alpine3.24
|
||||
in_image() {
|
||||
local image=$1 entrypoint=$2
|
||||
shift 2
|
||||
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
|
||||
}
|
||||
source ./docker-images.sh
|
||||
|
||||
in_image "$node_image" npm ci
|
||||
in_image "$node_image" npm run typecheck
|
||||
@@ -26,3 +18,7 @@ fi
|
||||
|
||||
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" npx tsc -p package-tests
|
||||
in_image "$floor_image" node package-tests/node-floor.js
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
unsupported-nesting-depth
|
||||
@@ -0,0 +1,2 @@
|
||||
:::tableCell {colwidth="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"}
|
||||
:::
|
||||
@@ -0,0 +1,10 @@
|
||||
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,11 @@
|
||||
import { adfToMarkdown, isAdfDocument, markdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
|
||||
|
||||
const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
|
||||
|
||||
const emitted: Result<string> = adfToMarkdown(document)
|
||||
const parsed: Result<AdfDocument, ParseError> = markdownToAdf('x\n')
|
||||
const guarded: boolean = isAdfDocument(document)
|
||||
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
|
||||
const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line
|
||||
|
||||
export const surface = { code, guarded, line }
|
||||
@@ -0,0 +1,9 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { adfToMarkdown, markdownToAdf } from '@larvit/adf-codec'
|
||||
|
||||
const document = { content: [{ content: [{ marks: [{ type: 'em' }], text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
|
||||
|
||||
const emitted = adfToMarkdown(document)
|
||||
assert.ok(emitted.ok, emitted.ok ? '' : emitted.error.message)
|
||||
assert.deepEqual(markdownToAdf(emitted.value), { ok: true, value: document })
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"types": []
|
||||
},
|
||||
"include": ["consumer.ts"]
|
||||
}
|
||||
@@ -9,10 +9,20 @@
|
||||
"url": "git+https://gitea.larvit.se/larvit/adf-codec.git"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json"
|
||||
},
|
||||
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
source ./docker-images.sh
|
||||
|
||||
read_field() {
|
||||
in_image "$node_image" npm pkg get "$1" | tr -d '"\r'
|
||||
}
|
||||
|
||||
if [ "$(read_field 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
|
||||
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"
|
||||
+25
-8
@@ -6,8 +6,8 @@
|
||||
"customType": "regex",
|
||||
"datasourceTemplate": "docker",
|
||||
"depNameTemplate": "denoland/deno",
|
||||
"description": "Pin the Deno image ci.sh runs",
|
||||
"managerFilePatterns": ["ci.sh"],
|
||||
"description": "Pin the Deno image the gate runs",
|
||||
"managerFilePatterns": ["docker-images.sh"],
|
||||
"matchStrings": ["denoland/deno:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"versioningTemplate": "docker"
|
||||
},
|
||||
@@ -15,17 +15,27 @@
|
||||
"customType": "regex",
|
||||
"datasourceTemplate": "docker",
|
||||
"depNameTemplate": "node",
|
||||
"description": "Pin the node image ci.sh runs",
|
||||
"managerFilePatterns": ["ci.sh"],
|
||||
"matchStrings": ["node:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"description": "Pin the node image the gate runs",
|
||||
"managerFilePatterns": ["docker-images.sh"],
|
||||
"matchStrings": ["node_image=node:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"versioningTemplate": "docker"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"datasourceTemplate": "docker",
|
||||
"depNameTemplate": "node-floor",
|
||||
"description": "Pin the node image proving engines.node, held to that major",
|
||||
"managerFilePatterns": ["docker-images.sh"],
|
||||
"matchStrings": ["floor_image=node:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"packageNameTemplate": "node",
|
||||
"versioningTemplate": "docker"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"datasourceTemplate": "docker",
|
||||
"depNameTemplate": "oven/bun",
|
||||
"description": "Pin the Bun image ci.sh runs",
|
||||
"managerFilePatterns": ["ci.sh"],
|
||||
"description": "Pin the Bun image the gate runs",
|
||||
"managerFilePatterns": ["docker-images.sh"],
|
||||
"matchStrings": ["oven/bun:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"versioningTemplate": "docker"
|
||||
},
|
||||
@@ -39,5 +49,12 @@
|
||||
"versioningTemplate": "docker"
|
||||
}
|
||||
],
|
||||
"extends": ["config:recommended"]
|
||||
"extends": ["config:recommended"],
|
||||
"packageRules": [
|
||||
{
|
||||
"allowedVersions": "<19",
|
||||
"description": "engines.node states >=18, so the image proving it stays on 18",
|
||||
"matchDepNames": ["node-floor"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { adfDocumentFault, isAdfDocument } from './document.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
|
||||
function fault(value: unknown): string {
|
||||
return adfDocumentFault(value) ?? 'accepted'
|
||||
}
|
||||
|
||||
function nested(levels: number): JsonValue {
|
||||
let value: JsonValue = 1
|
||||
for (let level = 0; level < levels; level += 1) value = [value]
|
||||
return value
|
||||
}
|
||||
|
||||
function withAttribute(value: JsonValue): unknown {
|
||||
return { content: [{ attrs: { a: value }, type: 'paragraph' }], type: 'doc', version: 1 }
|
||||
}
|
||||
|
||||
test('accepts an editor-normal document', () => {
|
||||
assert.equal(isAdfDocument({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true)
|
||||
assert.equal(isAdfDocument({ type: 'doc', version: 1 }), true)
|
||||
@@ -44,6 +56,15 @@ 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('accepts the JSON values an attribute may hold', () => {
|
||||
assert.equal(isAdfDocument({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true)
|
||||
assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
||||
|
||||
+12
-10
@@ -1,4 +1,5 @@
|
||||
import { isJsonValue, type JsonValue } from '../json-value.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
|
||||
export type AdfAttributes = { [key: string]: JsonValue }
|
||||
|
||||
@@ -25,7 +26,7 @@ const documentKeys = ['content', 'type', 'version']
|
||||
const markKeys = ['attrs', 'type']
|
||||
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
|
||||
|
||||
export function adfDocumentFault(value: unknown): string | undefined {
|
||||
export function adfDocumentFault(value: unknown, levels: number = largestNesting): string | undefined {
|
||||
if (!isRecord(value)) return `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}`
|
||||
@@ -37,7 +38,7 @@ export function adfDocumentFault(value: unknown): string | undefined {
|
||||
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) ? undefined : "an ADF document's content holds ADF nodes: one of them is not"
|
||||
return isNodeArray(content, levels) ? undefined : "an ADF document's content holds ADF nodes: one of them is not"
|
||||
}
|
||||
|
||||
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
|
||||
@@ -50,23 +51,23 @@ export function isAdfDocument(value: unknown): value is AdfDocument {
|
||||
}
|
||||
|
||||
export function isAdfNode(value: unknown): value is AdfNode {
|
||||
return isNodeArray([value])
|
||||
return isNodeArray([value], largestNesting)
|
||||
}
|
||||
|
||||
export function isAdfMark(value: unknown): value is AdfMark {
|
||||
export function isAdfMark(value: unknown, levels: number = largestNesting): 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'])
|
||||
return !('attrs' in value) || isAttributes(value['attrs'], levels)
|
||||
}
|
||||
|
||||
function isNodeArray(value: readonly unknown[]): boolean {
|
||||
function isNodeArray(value: readonly unknown[], levels: number): boolean {
|
||||
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'])) return false
|
||||
if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) 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 ('text' in node && typeof node['text'] !== 'string') return false
|
||||
if ('content' in node) {
|
||||
const content = node['content']
|
||||
@@ -81,8 +82,9 @@ function isArrayOf<T>(value: unknown, guard: (item: unknown) => item is T): valu
|
||||
return Array.isArray(value) && [...value].every(guard)
|
||||
}
|
||||
|
||||
function isAttributes(value: unknown): value is AdfAttributes {
|
||||
return isRecord(value) && isJsonValue(value)
|
||||
// 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 isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -45,6 +45,11 @@ 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
|
||||
@@ -294,10 +299,10 @@ function readQuotedValue(text: string, index: number): Read<{ end: number; value
|
||||
return { value: { end: cursor + 1, value: { decoded: parsed, spelling } } }
|
||||
}
|
||||
|
||||
function parseJson(raw: string): JsonValue | undefined {
|
||||
function parseJson(raw: string, levels: number = largestNesting): JsonValue | undefined {
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw)
|
||||
return isJsonValue(value) ? value : undefined
|
||||
return isJsonValue(value, levels) ? value : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { JsonValue } from '../../json-value.ts'
|
||||
import type { Result } from '../../result.ts'
|
||||
import { adfToMarkdown } from '../../index.ts'
|
||||
import { adfToMarkdown, markdownToAdf } from '../../index.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
|
||||
function document(...content: AdfNode[]): AdfDocument {
|
||||
return { content, type: 'doc', version: 1 }
|
||||
@@ -334,10 +336,18 @@ 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 }
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))),
|
||||
"not-an-adf-document: an ADF document's content holds ADF nodes: one of them is not",
|
||||
)
|
||||
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' }
|
||||
}
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper)
|
||||
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))) })
|
||||
})
|
||||
|
||||
test('escapes a literal delimiter that would merge with an emitted one', () => {
|
||||
|
||||
@@ -24,6 +24,9 @@ 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 (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
||||
const blocks = emitBlocks(document.content ?? [], 'document', [], 0)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AdfAttributes } from '../../adf/document.ts'
|
||||
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
|
||||
import type { DirectiveAttributes } from '../directive-syntax.ts'
|
||||
import { attributeValue, spellAttributeValue } from '../directive-syntax.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { attributeNestingFault, attributeValue, spellAttributeValue } from '../directive-syntax.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
|
||||
export type Elsewhere = { key: string; slot: 'argument' | 'content' }
|
||||
|
||||
@@ -22,7 +22,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) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path)
|
||||
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 (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path)
|
||||
attrs[key] = read.value
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
|
||||
import { attributeNestingFault, 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,6 +94,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 (marks === undefined) {
|
||||
return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON: this one is not`, path)
|
||||
|
||||
@@ -431,12 +431,18 @@ test('names the attribute a node holds no reading for', () => {
|
||||
assert.equal(content(markdownToAdf(':::table {isNumberColumnEnabled=yes}\n:::\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean')
|
||||
assert.equal(content(markdownToAdf('::media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number')
|
||||
assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340,"}\n:::\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json')
|
||||
const deep = `${'['.repeat(largestNesting + 2)}${']'.repeat(largestNesting + 2)}`
|
||||
assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${deep}"}\n:::\n`)), 'unsupported-node-shape: the colwidth attribute of tableCell is no json')
|
||||
assert.equal(content(markdownToAdf(':::panel info {panelType=note}\nx\n:::\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument, never in {attrs}')
|
||||
assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}')
|
||||
})
|
||||
|
||||
test('names the depth an attribute value nests past, never the kind the JSON reads as', () => {
|
||||
const nested = (levels: number): string => `${'['.repeat(levels)}1${']'.repeat(levels)}`
|
||||
const deeper = (key: string, type: string): string => `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels the parser 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(`::media {width="${nested(largestNesting + 1)}"}\n`)), 'unsupported-node-shape: the width attribute of media is no number')
|
||||
})
|
||||
|
||||
test('names the attribute value spelled outside the canonical form', () => {
|
||||
assert.equal(content(markdownToAdf('::rule {localId="a-1"}\n')), 'unsupported-node-shape: rule spells its localId attribute as localId=a-1')
|
||||
assert.equal(content(markdownToAdf('::media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20')
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const sourceRoot = dirname(fileURLToPath(import.meta.url))
|
||||
const union = /export type ConvertErrorCode =\n((?:\s+\| '[a-z-]+'\n)+)/
|
||||
const declared = /'([a-z-]+)'/g
|
||||
const callSite = /(?:failure\(|code: )'([a-z-]+)'/g
|
||||
|
||||
function declaredCodes(): string[] {
|
||||
const source = readFileSync(join(sourceRoot, 'result.ts'), 'utf8')
|
||||
const members = union.exec(source)?.[1]
|
||||
assert.notEqual(members, undefined, 'result.ts declares no ConvertErrorCode union')
|
||||
return [...(members ?? '').matchAll(declared)].map(([, name]) => name ?? '').sort()
|
||||
}
|
||||
|
||||
function calledCodes(): string[] {
|
||||
const called = new Set<string>()
|
||||
for (const name of readdirSync(sourceRoot, { encoding: 'utf8', recursive: true })) {
|
||||
if (!name.endsWith('.ts') || name.endsWith('.test.ts') || name === 'result.ts') continue
|
||||
for (const [, code] of readFileSync(join(sourceRoot, name), 'utf8').matchAll(callSite)) called.add(code ?? '')
|
||||
}
|
||||
return [...called].sort()
|
||||
}
|
||||
|
||||
// The list is frozen at 0.1.0 (AGENTS.md §8), so a code outliving its cause is a removal that costs a MAJOR.
|
||||
test('every ConvertErrorCode is the code of a production call site, and every call site names a declared one', () => {
|
||||
assert.deepEqual(calledCodes(), declaredCodes())
|
||||
})
|
||||
@@ -482,3 +482,30 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
|
||||
takes (§11), so six codes reach a `markdownToAdf` caller as well as an `adfToMarkdown` one.
|
||||
The trailing pipe of a pipe-table row is optional in input, not required; the leading one
|
||||
is what every row must carry.
|
||||
- [x] **5c — The build and the release pipeline.** Split out of 5, which kept only the
|
||||
maintainer's own acts. The build: `tsconfig.build.json` gains emit of JS and `.d.ts` to
|
||||
`dist/` (its own `allowImportingTsExtensions` forces `noEmit`, so
|
||||
`rewriteRelativeImportExtensions` lands beside it), plus `exports`/`files` in
|
||||
`package.json`. Publish-on-version-change (§9) as `publish.sh`, run by a `main`-only job
|
||||
needing the gate. The `ConvertErrorCode` freeze (§8) is checkable here: 3h landed the last
|
||||
decision `corpus/unspellable/` held and the directory went with it, so what the code list
|
||||
holds from here is permanent. The parser's own code additions are read here as one list
|
||||
before that freeze — nine sessions mint them independently, and one cause wearing two codes
|
||||
is breaking to undo after `0.1.0`. That read gets a test rather than an eye — every
|
||||
`ConvertErrorCode` member named at a production call site, the way `spec.test.ts` guards the
|
||||
node tables — since `unspelled-block-separation` outlived its cause until 3h went looking.
|
||||
All thirteen have a call site; the audit's find was the depth one 5 predicted, read wrong in
|
||||
its own text: an attribute value past 500 levels was `unsupported-node-shape` on parse and
|
||||
`not-an-adf-document` on emit, the document guard counting the `attrs` object as a level the
|
||||
parser does not, so a value at exactly 500 parsed into a document the emitter then refused.
|
||||
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`.
|
||||
`engines.node` gets its one-line proof too — the built entrypoint imported and round-tripped
|
||||
under a pinned Node 18 image, which cannot run the suite that type stripping wants 22+ for,
|
||||
but proves exactly what the field claims. Beside it, the emitted `.d.ts` typechecked from a
|
||||
consumer's position: declaration emit leaves the `.ts` specifiers `rewriteRelativeImportExtensions`
|
||||
rewrites in the JavaScript, and nothing else in the repo reads them the way an installed
|
||||
consumer would. 3k's exception list landing after the release left the README's
|
||||
canonical-fixpoint sentence claiming more than `0.1.0` keeps — 3e names three shapes that
|
||||
parse and then refuse — so it now says a parse succeeding is no promise of a way back, and
|
||||
names them.
|
||||
|
||||
@@ -5,8 +5,8 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h
|
||||
|
||||
## Milestones
|
||||
|
||||
Shipping order: 3h, 3i, 3j, 5a, 5b, 5 → `0.1.0`; 4b and 4c → `0.1.1`; 4, 3k → `0.2.0`; 6, 7 →
|
||||
`0.3.0`.
|
||||
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0`; 4b and 4c → `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.
|
||||
|
||||
- [x] **0 — Scaffold.**
|
||||
@@ -108,45 +108,30 @@ 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.
|
||||
- [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret,
|
||||
the repo made public first (§6). The `ConvertErrorCode` freeze (§8) is checkable here: 3h
|
||||
landed the last decision `corpus/unspellable/` held and the directory went with it, so what
|
||||
the code list holds from here is permanent. The parser's own code
|
||||
additions are read here as one list before that freeze — nine sessions mint them
|
||||
independently, and one cause wearing two codes is breaking to undo after `0.1.0` — one is
|
||||
known already: a json attribute value past 500 levels reads `unsupported-node-shape` on
|
||||
parse but `unsupported-nesting-depth` through the carry on emit. That read
|
||||
gets a test rather than an eye — every `ConvertErrorCode` member named at a production call
|
||||
site, the way `spec.test.ts` guards the node tables — since `unspelled-block-separation`
|
||||
outlived its cause until 3h went looking. `0.1.0`
|
||||
is the markdown round-trip: both markdown directions, the types, `isAdfDocument`. The build
|
||||
lands here: `tsconfig.build.json` gains emit of JS and `.d.ts` to `dist/` (its own
|
||||
`allowImportingTsExtensions` forces `noEmit`, so `rewriteRelativeImportExtensions` lands
|
||||
beside it), plus `exports`/`files` in `package.json`. The
|
||||
maintainer's bump PR also removes `private: true`, the guard against any earlier publish.
|
||||
§6's browser half is first checkable here, 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, `ci.sh`'s three legs being two V8s and a JavaScriptCore that is
|
||||
not Safari's. `engines.node` gets its one-line proof here
|
||||
too — `import('./dist/index.js')` under a pinned Node 18 image, which cannot run the
|
||||
suite that type stripping wants 22+ for, but proves exactly what the field claims.
|
||||
- [ ] **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
|
||||
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. 3k's exception list
|
||||
landing after the release leaves the README's canonical-fixpoint sentence claiming more than
|
||||
`0.1.0` keeps — 3e names three shapes that parse and then refuse — so the release narrows
|
||||
that sentence or lists them. `[x](http://a\b)` is one to narrow it against: it parses
|
||||
cleanly and refuses on the way back, so a successful parse does not imply a spellable
|
||||
document.
|
||||
yet, and §8's pre-1.0 rules cover what the wider proof then finds.
|
||||
- [x] **5a — Rename to `@larvit/adf-codec`.**
|
||||
- [x] **5b — The consumer's error surface.**
|
||||
- [x] **5b1 — The error's source position.**
|
||||
- [x] **5b2 — The error messages.**
|
||||
- [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.
|
||||
- [ ] **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` /
|
||||
|
||||
+4
-1
@@ -7,9 +7,12 @@
|
||||
"types": [],
|
||||
|
||||
"allowImportingTsExtensions": true,
|
||||
"declaration": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"outDir": "dist",
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"rootDir": "src",
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
"strict": true,
|
||||
|
||||
Reference in New Issue
Block a user