Compare commits
9 Commits
d1ab7f713e
...
39039081ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 39039081ee | |||
| dfc91a651b | |||
| 0361646e1a | |||
| 047ce3bc42 | |||
| a774be59f4 | |||
| 7b726ef6ec | |||
| 503efa4f82 | |||
| 538e0ce665 | |||
| 4147f6623c |
@@ -117,8 +117,9 @@ A message names the violation, not the rule alone — a rule by itself states a
|
|||||||
must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose it
|
must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose it
|
||||||
names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and inline
|
names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and inline
|
||||||
alike, `\|` for every pipe row.
|
alike, `\|` for every pipe row.
|
||||||
Adding, removing or renaming a code is breaking, so a milestone meeting a new failure cause
|
Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose
|
||||||
reuses a code where one fits; the list is complete at `0.1.0`. A code names the
|
name reads true of it in both directions; where none does and a plain name exists, a new code — in
|
||||||
|
any 0.x minor, and after 1.0 only in a MAJOR (the maintainer, 2026-09-18). A code names the
|
||||||
cause; where one cause recurs across node types, across one mark's attributes or across
|
cause; where one cause recurs across node types, across one mark's attributes or across
|
||||||
directions, one code covers them all and
|
directions, one code covers them all and
|
||||||
`path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever
|
`path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever
|
||||||
@@ -147,7 +148,7 @@ not take — is
|
|||||||
`unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code
|
`unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code
|
||||||
across both directions for good, since the call site knows which direction it called and parting
|
across both directions for good, since the call site knows which direction it called and parting
|
||||||
them after `0.1.0` is MAJOR. `unmappable-html` names the version rather than the element: this one
|
them after `0.1.0` is MAJOR. `unmappable-html` names the version rather than the element: this one
|
||||||
converts no raw HTML, so at `0.3.0` the mapped elements stop erroring and the code stays for what
|
converts no raw HTML, so at `0.2.0` the mapped elements stop erroring and the code stays for what
|
||||||
no ADF node carries. A refusal found before its path is known — the block walk's, a directive
|
no ADF node carries. A refusal found before its path is known — the block walk's, a directive
|
||||||
reader's — is a `ConvertFault`, the code and message alone; the node walk attaches the path as it
|
reader's — is a `ConvertFault`, the code and message alone; the node walk attaches the path as it
|
||||||
descends, so a document reports its first error in document order. `not-an-adf-document` carries
|
descends, so a document reports its first error in document order. `not-an-adf-document` carries
|
||||||
@@ -176,7 +177,7 @@ A parse names a position for every refusal it returns, so the type says so rathe
|
|||||||
`Result<T, E extends ConvertError = ConvertError>`, and a direction reading a source returns
|
`Result<T, E extends ConvertError = ConvertError>`, and a direction reading a source returns
|
||||||
`Result<T, ParseError>` — `ConvertError` with `position` required. An optional field a direction
|
`Result<T, ParseError>` — `ConvertError` with `position` required. An optional field a direction
|
||||||
always fills is a branch a consumer cannot take, and the `!` §11 bans is how they take it anyway.
|
always fills is a branch a consumer cannot take, and the `!` §11 bans is how they take it anyway.
|
||||||
`htmlToAdf` inherits this at `0.3.0`; the composed `markdownToHtml` and `htmlToMarkdown` keep the
|
`htmlToAdf` inherits this at `0.2.0`; the composed `markdownToHtml` and `htmlToMarkdown` keep the
|
||||||
wide `Result<T>`, since half their refusals come from an emit stage that read no source.
|
wide `Result<T>`, since half their refusals come from an emit stage that read no source.
|
||||||
|
|
||||||
## 9. Release automation
|
## 9. Release automation
|
||||||
@@ -297,10 +298,17 @@ someone spells it or pins it.
|
|||||||
has — for the fallback to refuse at zero rather than walk again; counting every list twice
|
has — for the fallback to refuse at zero rather than walk again; counting every list twice
|
||||||
halved the list limit, counting the directive form once doubled the parser's frames per level
|
halved the list limit, counting the directive form once doubled the parser's frames per level
|
||||||
(the maintainer, 2026-09-18).
|
(the maintainer, 2026-09-18).
|
||||||
|
- Nothing spreads an unbounded array into a call — a node's siblings, a code block's held lines, a
|
||||||
|
mark run's segments: the argument list caps near 125k and throws a `RangeError` where a `Result`
|
||||||
|
is owed. A walk pushes one at a time. A literal spread (`[...value]`) is not the same thing and
|
||||||
|
is fine (4c).
|
||||||
- A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the
|
- A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the
|
||||||
line before it reads, `indexOf` — never a fresh slice per character, and a per-character walk
|
line before it reads, `indexOf` — never a fresh slice per character, and a per-character walk
|
||||||
hoists the scan that does not vary with the character. The pipeline persona feeds documents
|
hoists the scan that does not vary with the character. The pipeline persona feeds documents
|
||||||
nobody typed, and a megabyte through a quadratic walk is a minute rather than a millisecond.
|
nobody typed, and a megabyte through a quadratic walk is a minute rather than a millisecond. A
|
||||||
|
scan may keep what it read for a later walk of the same text, and the fallback where it kept
|
||||||
|
nothing must be the same reader over the same text at the same index, so the two cannot disagree
|
||||||
|
— which is what makes the kept value a memo rather than a second spelling (4c).
|
||||||
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the
|
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the
|
||||||
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
|
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
|
||||||
unrepresentable.
|
unrepresentable.
|
||||||
@@ -324,7 +332,9 @@ someone spells it or pins it.
|
|||||||
number is the markdown flavour's choice, not ADF's.
|
number is the markdown flavour's choice, not ADF's.
|
||||||
- Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a
|
- Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a
|
||||||
file does not repeat its directory in its name — `adf/document.ts`, never
|
file does not repeat its directory in its name — `adf/document.ts`, never
|
||||||
`adf/adf-document.ts`.
|
`adf/adf-document.ts`. A name is the noun `spec/flavour.md` or ADF's schema uses for the
|
||||||
|
thing; a directory follows a split the spec draws; a placement these rules leave open goes
|
||||||
|
beside its only reader, or in what both read where there are two (the maintainer, 2026-09-18).
|
||||||
- Reuse before adding; the smallest sufficient diff is the benchmark; no speculative generality —
|
- Reuse before adding; the smallest sufficient diff is the benchmark; no speculative generality —
|
||||||
a second consumer, or it goes.
|
a second consumer, or it goes.
|
||||||
|
|
||||||
@@ -373,6 +383,27 @@ Ask, don't guess: any choice where what the maintainer would pick is not near-ce
|
|||||||
and the answer lands as a decision in this file. The confidence bar is very high — asking too
|
and the answer lands as a decision in this file. The confidence bar is very high — asking too
|
||||||
often is the accepted cost, guessing wrong is not.
|
often is the accepted cost, guessing wrong is not.
|
||||||
|
|
||||||
|
An ask is a gap in this file, and its answer is the rule that closes the gap, never the instance
|
||||||
|
alone. Before asking, name the class the question belongs to and the earlier `(the maintainer, …)`
|
||||||
|
entries of that class; where a rule already decides it, apply it without asking, and where the rule
|
||||||
|
reads two ways on this input, that reading is the ask. Never ask "A or B?": state the gap, the
|
||||||
|
earlier asks of its class, the nearest text here, a candidate rule in this file's voice and section,
|
||||||
|
and the instance it yields, and ask for the rule. The maintainer answers the rule, the rule lands
|
||||||
|
here, and the instance follows from it in the chunk. A rule that keeps collecting instances is
|
||||||
|
wrong: rewrite it rather than append to it. `version` and `NPM_TOKEN` stay the maintainer's
|
||||||
|
whatever any rule says.
|
||||||
|
|
||||||
|
Rules the loop has settled (the maintainer, 2026-09-18):
|
||||||
|
|
||||||
|
- A finding inside the chunk's item is fixed in the chunk. Outside it, a new `todo.md` item, always
|
||||||
|
in a release, weighed against every item on that release by the personas and §1–§3 — an item it
|
||||||
|
outweighs moves later. A weighing no rule decides is asked as a gap.
|
||||||
|
- A stated number — 500 levels, the gate's seconds, the branch floor — is kept; a chunk that cannot
|
||||||
|
keep it asks, naming the number it can reach. A number the code needs and no rule states is a gap.
|
||||||
|
- Where the shipping order names no release for the next unchecked item, the chunk is planning that
|
||||||
|
release: every unscheduled item weighed as above, the order written in `todo.md`, and the
|
||||||
|
maintainer's approval taken before any code.
|
||||||
|
|
||||||
Reserved for the maintainer, never the agent: changing `version` in `package.json` (a bump on
|
Reserved for the maintainer, never the agent: changing `version` in `package.json` (a bump on
|
||||||
`main` publishes, §9 — every release is the maintainer's) and the `NPM_TOKEN` secret.
|
`main` publishes, §9 — every release is the maintainer's) and the `NPM_TOKEN` secret.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Lossless conversion between **Atlassian Document Format** (ADF), an extended mar
|
|||||||
an HTML dialect.
|
an HTML dialect.
|
||||||
|
|
||||||
**Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
|
**Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
|
||||||
`0.3.0`.**
|
`0.2.0`.**
|
||||||
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
|
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
|
||||||
[`spec/flavour.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/spec/flavour.md).
|
[`spec/flavour.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/spec/flavour.md).
|
||||||
Upgrading from `0.1.0`: [convert your markdown first](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/MIGRATION.md).
|
Upgrading from `0.1.0`: [convert your markdown first](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/MIGRATION.md).
|
||||||
@@ -44,10 +44,10 @@ adfToMarkdown(doc: AdfDocument): Result<string>
|
|||||||
markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
||||||
isAdfDocument(v: unknown): v is AdfDocument
|
isAdfDocument(v: unknown): v is AdfDocument
|
||||||
|
|
||||||
adfToHtml(doc: AdfDocument): Result<string> // 0.3.0
|
adfToHtml(doc: AdfDocument): Result<string> // 0.2.0
|
||||||
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.3.0
|
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0
|
||||||
markdownToHtml(markdown: string): Result<string> // 0.3.0, via ADF
|
markdownToHtml(markdown: string): Result<string> // 0.2.0, via ADF
|
||||||
htmlToMarkdown(html: string): Result<string> // 0.3.0, via ADF
|
htmlToMarkdown(html: string): Result<string> // 0.2.0, via ADF
|
||||||
```
|
```
|
||||||
|
|
||||||
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
|
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
|
||||||
@@ -75,17 +75,17 @@ UTF-16 code unit, a JavaScript string index rather than a codepoint or a byte of
|
|||||||
or before the refusal — currently the start of the line the enclosing block begins on; a later
|
or before the refusal — currently the start of the line the enclosing block begins on; a later
|
||||||
minor may narrow that, never widen it.
|
minor may narrow that, never widen it.
|
||||||
|
|
||||||
Parsing — `markdownToAdf`, and `htmlToAdf` at `0.3.0`:
|
Parsing — `markdownToAdf`, and `htmlToAdf` at `0.2.0`:
|
||||||
|
|
||||||
| Code | Fires when | What you can do |
|
| Code | Fires when | What you can do |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text |
|
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text |
|
||||||
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
|
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
|
||||||
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
|
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
|
||||||
| `unmappable-html` | the markdown holds a raw HTML tag, comment or processing instruction | remove it or write it in the flavour — ADF holds no raw-HTML node, and the element mapping lands at `0.3.0` |
|
| `unmappable-html` | the markdown holds a raw HTML tag, comment or processing instruction | remove it or write it in the flavour — ADF holds no raw-HTML node, and the element mapping lands at `0.2.0` |
|
||||||
| `unmappable-image` | an image sits inside other content, or carries a title | give the image a paragraph of its own and drop the title |
|
| `unmappable-image` | an image sits inside other content, or carries a title | give the image a paragraph of its own and drop the title |
|
||||||
|
|
||||||
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.3.0`:
|
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`:
|
||||||
|
|
||||||
| Code | Fires when | What you can do |
|
| Code | Fires when | What you can do |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -121,7 +121,7 @@ emit refuses:
|
|||||||
reference matching its definition only under Unicode case folding stays unresolved. Each is
|
reference matching its definition only under Unicode case folding stays unresolved. Each is
|
||||||
pinned `pending` in `corpus/commonmark-spec/exceptions.json`.
|
pinned `pending` in `corpus/commonmark-spec/exceptions.json`.
|
||||||
- Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a
|
- Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a
|
||||||
processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.3.0`.
|
processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.2.0`.
|
||||||
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
|
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
|
||||||
a carriage return, or a paragraph line beginning with a code span whose backticks read back as a
|
a carriage return, or a paragraph line beginning with a code span whose backticks read back as a
|
||||||
fence. Show the refusal and keep the document read-only; saving markdown you could not produce
|
fence. Show the refusal and keep the document read-only; saving markdown you could not produce
|
||||||
@@ -134,7 +134,7 @@ emit refuses:
|
|||||||
is the `taskList` directive.
|
is the `taskList` directive.
|
||||||
- A document nested deeper than 500 levels is an error result, not a stack overflow.
|
- A document nested deeper than 500 levels is an error result, not a stack overflow.
|
||||||
- The emitted formats are semver surface (AGENTS.md §8).
|
- The emitted formats are semver surface (AGENTS.md §8).
|
||||||
- **`0.3.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides
|
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides
|
||||||
`data-*` attributes. Foreign HTML maps a documented element set, an unmappable element is an
|
`data-*` attributes. Foreign HTML maps a documented element set, an unmappable element is an
|
||||||
error, and well-formed HTML only — no tag-soup recovery.
|
error, and well-formed HTML only — no tag-soup recovery.
|
||||||
|
|
||||||
|
|||||||
@@ -77,3 +77,9 @@ test('accepts the JSON values an attribute may hold', () => {
|
|||||||
assert.equal(isAdfDocument({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true)
|
assert.equal(isAdfDocument({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true)
|
||||||
assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("reads a node's siblings as a walk rather than as one call's arguments", () => {
|
||||||
|
const wide = { content: [{ content: Array.from({ length: 200000 }, () => ({ type: 'rule' })), type: 'blockquote' }], type: 'doc', version: 1 }
|
||||||
|
assert.equal(isAdfDocument(wide), true)
|
||||||
|
assert.equal(fault(wide), 'accepted')
|
||||||
|
})
|
||||||
|
|||||||
+2
-2
@@ -96,7 +96,7 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
|
|||||||
if ('content' in node) {
|
if ('content' in node) {
|
||||||
const content = node['content']
|
const content = node['content']
|
||||||
if (!Array.isArray(content)) return false
|
if (!Array.isArray(content)) return false
|
||||||
pending.push(...content)
|
for (const child of content) pending.push(child)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -109,7 +109,7 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
|
|||||||
if (node === undefined) continue
|
if (node === undefined) continue
|
||||||
const fault = attributesFault(nodeAttrs(node), node.type) ?? marksFault(nodeMarks(node))
|
const fault = attributesFault(nodeAttrs(node), node.type) ?? marksFault(nodeMarks(node))
|
||||||
if (fault !== undefined) return fault
|
if (fault !== undefined) return fault
|
||||||
pending.push(...nodeContent(node))
|
for (const child of nodeContent(node)) pending.push(child)
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { readEntityReference, replacementCharacter } from './entity-references.t
|
|||||||
|
|
||||||
export type LinePosition = 'first' | 'later'
|
export type LinePosition = 'first' | 'later'
|
||||||
|
|
||||||
|
// The suffix lengths of a text that spell a thematic break, and the marker each of them opens with.
|
||||||
|
export type ThematicBreakTail = { longest: number; marker: string; shortest: number }
|
||||||
|
|
||||||
type OpenHtmlBlock = { closer: RegExp | undefined; construct: string }
|
type OpenHtmlBlock = { closer: RegExp | undefined; construct: string }
|
||||||
|
|
||||||
type HtmlBlockCondition = { closer: RegExp | undefined; construct: string | undefined; interrupts: boolean; start: RegExp }
|
type HtmlBlockCondition = { closer: RegExp | undefined; construct: string | undefined; interrupts: boolean; start: RegExp }
|
||||||
@@ -52,6 +55,7 @@ const htmlBlockConditions: HtmlBlockCondition[] = [
|
|||||||
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
||||||
const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/
|
const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/
|
||||||
const blankLine = /^[ \t]*$/
|
const blankLine = /^[ \t]*$/
|
||||||
|
const breakMarkers = '*-_'
|
||||||
const codeFenceOpener = /^(`{3,}|~{3,})/
|
const codeFenceOpener = /^(`{3,}|~{3,})/
|
||||||
const pipeClaim = /^\|/
|
const pipeClaim = /^\|/
|
||||||
const bulletListOpener = /^[*+-](?:[ \t]|$)/
|
const bulletListOpener = /^[*+-](?:[ \t]|$)/
|
||||||
@@ -63,7 +67,6 @@ const emailLabelSource = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
|
|||||||
const emailAutolink = new RegExp(`<${emailNameSource}@${emailLabelSource}(?:\\.${emailLabelSource})*>`, 'y')
|
const emailAutolink = new RegExp(`<${emailNameSource}@${emailLabelSource}(?:\\.${emailLabelSource})*>`, 'y')
|
||||||
const orderedListOpener = /^(\d{1,9})(?:[.)])(?:[ \t]|$)/
|
const orderedListOpener = /^(\d{1,9})(?:[.)])(?:[ \t]|$)/
|
||||||
const setextUnderline = /^(=+|-+)[ \t]*$/
|
const setextUnderline = /^(=+|-+)[ \t]*$/
|
||||||
const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
|
|
||||||
const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u
|
const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u
|
||||||
|
|
||||||
export function atxHeading(line: string): { level: number; text: string } | undefined {
|
export function atxHeading(line: string): { level: number; text: string } | undefined {
|
||||||
@@ -118,7 +121,7 @@ export function decodeTextEscapes(text: string): string {
|
|||||||
|
|
||||||
export function escapesLineClaim(line: string, offset: number, position: LinePosition): boolean {
|
export function escapesLineClaim(line: string, offset: number, position: LinePosition): boolean {
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
if (firstCharacterOpeners.some((opener) => opener.test(line)) || thematicBreak.test(line)) return true
|
if (firstCharacterOpeners.some((opener) => opener.test(line)) || isThematicBreak(line)) return true
|
||||||
if (openingHtmlBlock(line, position === 'later') !== undefined) return true
|
if (openingHtmlBlock(line, position === 'later') !== undefined) return true
|
||||||
return position === 'later' && setextUnderline.test(line)
|
return position === 'later' && setextUnderline.test(line)
|
||||||
}
|
}
|
||||||
@@ -168,7 +171,34 @@ export function isBlankLine(line: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isThematicBreak(line: string): boolean {
|
export function isThematicBreak(line: string): boolean {
|
||||||
return thematicBreak.test(line)
|
return holdsThematicBreak(thematicBreakTail(line), line)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function thematicBreakTail(text: string): ThematicBreakTail | undefined {
|
||||||
|
let marker: string | undefined
|
||||||
|
let markers = 0
|
||||||
|
let first = text.length
|
||||||
|
let third = text.length
|
||||||
|
let cursor = text.length
|
||||||
|
while (cursor > 0) {
|
||||||
|
const character = text.charAt(cursor - 1)
|
||||||
|
if (marker === undefined ? breakMarkers.includes(character) : character === marker) {
|
||||||
|
marker = character
|
||||||
|
markers += 1
|
||||||
|
first = cursor - 1
|
||||||
|
if (markers === 3) third = cursor - 1
|
||||||
|
} else if (!spaceOrTab(character)) break
|
||||||
|
cursor -= 1
|
||||||
|
}
|
||||||
|
if (marker === undefined || markers < 3) return undefined
|
||||||
|
return { longest: text.length - first, marker, shortest: text.length - third }
|
||||||
|
}
|
||||||
|
|
||||||
|
// `text` is a suffix of what `tail` was read from, or opens with a space: a length alone names a
|
||||||
|
// suffix, and the marker check is what refuses one starting mid-run — `--- ---` holds ` ---`.
|
||||||
|
export function holdsThematicBreak(tail: ThematicBreakTail | undefined, text: string): boolean {
|
||||||
|
if (tail === undefined) return false
|
||||||
|
return text.length >= tail.shortest && text.length <= tail.longest && text.charAt(0) === tail.marker
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUnicodeWhitespace(character: string): boolean {
|
export function isUnicodeWhitespace(character: string): boolean {
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ export type DirectiveLine =
|
|||||||
| { argument: string | undefined; attributes: DirectiveAttributes; kind: 'opener'; name: string }
|
| { argument: string | undefined; attributes: DirectiveAttributes; kind: 'opener'; name: string }
|
||||||
| { kind: 'closer'; name: string }
|
| { kind: 'closer'; name: string }
|
||||||
|
|
||||||
export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string }
|
// spans keys index content, so the two travel together: separate them and every offset is wrong.
|
||||||
|
export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string; spans: NestedSpans }
|
||||||
|
|
||||||
|
export type NestedSpans = ReadonlyMap<number, DirectiveSpan>
|
||||||
|
|
||||||
export type Read<T> = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T }
|
export type Read<T> = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T }
|
||||||
|
|
||||||
@@ -25,7 +28,9 @@ type Attributes = { attributes: DirectiveAttributes; end: number }
|
|||||||
|
|
||||||
type AttributePair = { end: number; key: string; value: DirectiveValue }
|
type AttributePair = { end: number; key: string; value: DirectiveValue }
|
||||||
|
|
||||||
type Content = { content: string | undefined; end: number }
|
type Content = { content: string | undefined; end: number; spans: NestedSpans }
|
||||||
|
|
||||||
|
type DirectiveContent = { end: number; spans: NestedSpans }
|
||||||
|
|
||||||
export const directivePrefix = '!adf:'
|
export const directivePrefix = '!adf:'
|
||||||
|
|
||||||
@@ -41,6 +46,8 @@ const quotedEscapes = new RegExp(reservedSource, 'g')
|
|||||||
const rawReserved = new RegExp(reservedSource)
|
const rawReserved = new RegExp(reservedSource)
|
||||||
const noAttributes: DirectiveAttributes = new Map()
|
const noAttributes: DirectiveAttributes = new Map()
|
||||||
|
|
||||||
|
export const noSpans: NestedSpans = new Map()
|
||||||
|
|
||||||
export const directiveEscape = `\\${directivePrefix} keeps the prefix literal`
|
export const directiveEscape = `\\${directivePrefix} keeps the prefix literal`
|
||||||
|
|
||||||
const closerFault = `a closer carries nothing after its name: this one does; ${directiveEscape}`
|
const closerFault = `a closer carries nothing after its name: this one does; ${directiveEscape}`
|
||||||
@@ -213,14 +220,14 @@ function readNestedDirective(text: string, index: number, depth: number): Read<D
|
|||||||
if (slot.fault !== undefined) return { fault: slot.fault }
|
if (slot.fault !== undefined) return { fault: slot.fault }
|
||||||
const attributes = readAttributesAt(text, slot.value.end, slot.value.content === undefined)
|
const attributes = readAttributesAt(text, slot.value.end, slot.value.content === undefined)
|
||||||
if (attributes.fault !== undefined) return { fault: attributes.fault }
|
if (attributes.fault !== undefined) return { fault: attributes.fault }
|
||||||
return { value: { attributes: attributes.value.attributes, content: slot.value.content, length: attributes.value.end - index, name } }
|
return { value: { attributes: attributes.value.attributes, content: slot.value.content, length: attributes.value.end - index, name, spans: slot.value.spans } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function readContentSlot(text: string, index: number, depth: number): Read<Content> {
|
function readContentSlot(text: string, index: number, depth: number): Read<Content> {
|
||||||
if (text.charAt(index) !== '[') return { value: { content: undefined, end: index } }
|
if (text.charAt(index) !== '[') return { value: { content: undefined, end: index, spans: noSpans } }
|
||||||
const close = readDirectiveContent(text, index + 1, depth)
|
const close = readDirectiveContent(text, index + 1, depth)
|
||||||
if (close.fault !== undefined) return { fault: close.fault }
|
if (close.fault !== undefined) return { fault: close.fault }
|
||||||
return { value: { content: text.slice(index + 1, close.value), end: close.value + 1 } }
|
return { value: { content: text.slice(index + 1, close.value.end), end: close.value.end + 1, spans: close.value.spans } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function readAttributesAt(text: string, index: number, braceClaims: boolean): Read<Attributes> {
|
function readAttributesAt(text: string, index: number, braceClaims: boolean): Read<Attributes> {
|
||||||
@@ -231,7 +238,8 @@ function readAttributesAt(text: string, index: number, braceClaims: boolean): Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A code span, an escape and a nested directive each bind before the content's own closing bracket.
|
// A code span, an escape and a nested directive each bind before the content's own closing bracket.
|
||||||
function readDirectiveContent(text: string, start: number, depth: number): Read<number> {
|
function readDirectiveContent(text: string, start: number, depth: number): Read<DirectiveContent> {
|
||||||
|
const spans = new Map<number, DirectiveSpan>()
|
||||||
let brackets = 0
|
let brackets = 0
|
||||||
let cursor = start
|
let cursor = start
|
||||||
while (cursor < text.length && text.charAt(cursor) !== '\n') {
|
while (cursor < text.length && text.charAt(cursor) !== '\n') {
|
||||||
@@ -247,12 +255,13 @@ function readDirectiveContent(text: string, start: number, depth: number): Read<
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const nested = readNestedDirective(text, cursor, depth + 1)
|
const nested = readNestedDirective(text, cursor, depth + 1)
|
||||||
|
if (nested?.fault !== undefined) return { fault: nested.fault }
|
||||||
if (nested !== undefined) {
|
if (nested !== undefined) {
|
||||||
if (nested.fault !== undefined) return { fault: nested.fault }
|
spans.set(cursor - start, nested.value)
|
||||||
cursor += nested.value.length
|
cursor += nested.value.length
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (character === ']' && brackets === 0) return { value: cursor }
|
if (character === ']' && brackets === 0) return { value: { end: cursor, spans } }
|
||||||
if (character === '[') brackets += 1
|
if (character === '[') brackets += 1
|
||||||
if (character === ']') brackets -= 1
|
if (character === ']') brackets -= 1
|
||||||
cursor += 1
|
cursor += 1
|
||||||
|
|||||||
@@ -736,3 +736,8 @@ test('carries whitespace CommonMark strips in the reserved text directive', () =
|
|||||||
// CommonMark strips spaces and tabs alone, so the whitespace beside them is plain text.
|
// CommonMark strips spaces and tabs alone, so the whitespace beside them is plain text.
|
||||||
assert.equal(emitted({ text: '\va\f', type: 'text' }), '\va\f\n')
|
assert.equal(emitted({ text: '\va\f', type: 'text' }), '\va\f\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("joins a mark run's segments as a walk rather than as one call's arguments", () => {
|
||||||
|
const run = Array.from({ length: 200000 }, (): AdfNode => ({ marks: [{ type: 'strong' }], text: 'a', type: 'text' }))
|
||||||
|
assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${'a'.repeat(200000)}**\n`)
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
|||||||
import type { InlineDirective } from '../../adf/inline-directives.ts'
|
import type { InlineDirective } from '../../adf/inline-directives.ts'
|
||||||
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
|
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
|
||||||
import { carriedInline } from '../opaque-carry.ts'
|
import { carriedInline } from '../opaque-carry.ts'
|
||||||
import { claimsLine, holdsNullCharacter } from '../commonmark-grammar.ts'
|
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark-grammar.ts'
|
||||||
import { commonMarkLink, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
import { commonMarkLink, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||||
import { escapeUnbalanced, spellDestination } from '../link-syntax.ts'
|
import { escapeUnbalanced, spellDestination } from '../link-syntax.ts'
|
||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
@@ -129,8 +129,8 @@ function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean)
|
|||||||
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
|
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
|
||||||
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
|
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
|
||||||
const body = segment.text.slice(head.length)
|
const body = segment.text.slice(head.length)
|
||||||
const tail = trailing ? (/[ \t]+$/.exec(body)?.[0] ?? '') : ''
|
const middle = trailing ? trimTrailingSpace(body) : body
|
||||||
const middle = body.slice(0, body.length - tail.length)
|
const tail = body.slice(middle.length)
|
||||||
const edges: InlineSegment[] = []
|
const edges: InlineSegment[] = []
|
||||||
if (head !== '') edges.push(carriedText(head))
|
if (head !== '') edges.push(carriedText(head))
|
||||||
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
|
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
|
||||||
@@ -166,7 +166,7 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
|
|||||||
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
|
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
|
||||||
if (!emitted.ok) return emitted
|
if (!emitted.ok) return emitted
|
||||||
if (emitted.value.carry !== undefined) return emitted
|
if (emitted.value.carry !== undefined) return emitted
|
||||||
segments.push(...emitted.value.segments)
|
for (const segment of emitted.value.segments) segments.push(segment)
|
||||||
}
|
}
|
||||||
return success({ segments })
|
return success({ segments })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export function readLabel(text: string, offset: number): LinkPart | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeLabel(raw: string): string {
|
export function normalizeLabel(raw: string): string {
|
||||||
return raw
|
const collapsed = raw.replace(/[ \t\n]+/g, ' ')
|
||||||
.replace(/^[ \t\n]+|[ \t\n]+$/g, '')
|
const start = collapsed.startsWith(' ') ? 1 : 0
|
||||||
.replace(/[ \t\n]+/g, ' ')
|
const end = collapsed.endsWith(' ') ? collapsed.length - 1 : collapsed.length
|
||||||
.toLowerCase()
|
return collapsed.slice(start, end).toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readDestination(text: string, offset: number): LinkPart | undefined {
|
export function readDestination(text: string, offset: number): LinkPart | undefined {
|
||||||
|
|||||||
@@ -118,3 +118,7 @@ test('closes the containers a closer names past as unclosed, and crosses no list
|
|||||||
assert.deepEqual(kinds('!adf:rule {localId=a-1}\nPart.\n!adf:/rule\n'), ['fault', 'paragraph'])
|
assert.deepEqual(kinds('!adf:rule {localId=a-1}\nPart.\n!adf:/rule\n'), ['fault', 'paragraph'])
|
||||||
assert.deepEqual(kinds('!adf:rule {localId=a-1}\n!adf:/rule\n!adf:/rule\n'), ['fault', 'fault'])
|
assert.deepEqual(kinds('!adf:rule {localId=a-1}\n!adf:/rule\n!adf:/rule\n'), ['fault', 'fault'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("releases an indented code block's held blank lines as a walk rather than as one call's arguments", () => {
|
||||||
|
assert.deepEqual(kinds(` a\n${'\n'.repeat(200000)} b\n`), ['code'])
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
claimsPipeLine,
|
claimsPipeLine,
|
||||||
closingCodeFence,
|
closingCodeFence,
|
||||||
decodeTextEscapes,
|
decodeTextEscapes,
|
||||||
|
holdsThematicBreak,
|
||||||
isBlankLine,
|
isBlankLine,
|
||||||
isThematicBreak,
|
isThematicBreak,
|
||||||
listMarker,
|
listMarker,
|
||||||
@@ -14,6 +15,8 @@ import {
|
|||||||
openingHtmlBlock,
|
openingHtmlBlock,
|
||||||
replaceNullCharacters,
|
replaceNullCharacters,
|
||||||
setextHeadingLevel,
|
setextHeadingLevel,
|
||||||
|
thematicBreakTail,
|
||||||
|
type ThematicBreakTail,
|
||||||
} from '../commonmark-grammar.ts'
|
} from '../commonmark-grammar.ts'
|
||||||
import { blockDirectiveForm } from '../block-directive-forms.ts'
|
import { blockDirectiveForm } from '../block-directive-forms.ts'
|
||||||
import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts'
|
import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts'
|
||||||
@@ -143,10 +146,11 @@ function blockquoteRest(opener: Line): Line | undefined {
|
|||||||
|
|
||||||
function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: number): { opened: boolean; rest: Line } {
|
function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: number): { opened: boolean; rest: Line } {
|
||||||
const unmatched = walk.stack[depth]
|
const unmatched = walk.stack[depth]
|
||||||
|
const tail = thematicBreakTail(line.text)
|
||||||
let opened = false
|
let opened = false
|
||||||
let rest = line
|
let rest = line
|
||||||
while (leadingColumns(rest) < indentedCodeColumns) {
|
while (leadingColumns(rest) < indentedCodeColumns) {
|
||||||
const start = containerStart(rest, opened ? false : paragraphOpen, opened ? undefined : unmatched, walk.position)
|
const start = containerStart(rest, tail, opened ? false : paragraphOpen, opened ? undefined : unmatched, walk.position)
|
||||||
if (start === undefined) break
|
if (start === undefined) break
|
||||||
if (!opened) closeContainers(walk, depth)
|
if (!opened) closeContainers(walk, depth)
|
||||||
opened = true
|
opened = true
|
||||||
@@ -156,11 +160,17 @@ function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: n
|
|||||||
return { opened, rest }
|
return { opened, rest }
|
||||||
}
|
}
|
||||||
|
|
||||||
function containerStart(line: Line, paragraphOpen: boolean, enclosing: OpenContainer | undefined, position: SourcePosition): ContainerStart | undefined {
|
function containerStart(
|
||||||
|
line: Line,
|
||||||
|
tail: ThematicBreakTail | undefined,
|
||||||
|
paragraphOpen: boolean,
|
||||||
|
enclosing: OpenContainer | undefined,
|
||||||
|
position: SourcePosition,
|
||||||
|
): ContainerStart | undefined {
|
||||||
const opener = removeColumns(line, largestOpenerIndentation)
|
const opener = removeColumns(line, largestOpenerIndentation)
|
||||||
const blockquote = blockquoteRest(opener)
|
const blockquote = blockquoteRest(opener)
|
||||||
if (blockquote !== undefined) return { kind: 'blockquote', rest: blockquote }
|
if (blockquote !== undefined) return { kind: 'blockquote', rest: blockquote }
|
||||||
if (isThematicBreak(opener.text)) return undefined
|
if (holdsThematicBreak(tail, opener.text)) return undefined
|
||||||
return itemStart(line, opener, paragraphOpen, enclosing, position)
|
return itemStart(line, opener, paragraphOpen, enclosing, position)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +365,8 @@ function readIndentedCodeLine(leaf: Extract<OpenLeaf, { kind: 'indented-code' }>
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (leadingColumns(line) < indentedCodeColumns) return false
|
if (leadingColumns(line) < indentedCodeColumns) return false
|
||||||
leaf.lines.push(...leaf.held, removeColumns(line, indentedCodeColumns).text)
|
for (const held of leaf.held) leaf.lines.push(held)
|
||||||
|
leaf.lines.push(removeColumns(line, indentedCodeColumns).text)
|
||||||
leaf.held.length = 0
|
leaf.held.length = 0
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import type { DirectiveSpan } from '../directive-syntax.ts'
|
import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
|
||||||
import type { EmphasisPairing } from '../emphasis-matching.ts'
|
import type { EmphasisPairing } from '../emphasis-matching.ts'
|
||||||
import type { LineContainer } from '../emit/line-escaping.ts'
|
import type { LineContainer } from '../emit/line-escaping.ts'
|
||||||
import type { LinkDefinition } from '../link-syntax.ts'
|
import type { LinkDefinition } from '../link-syntax.ts'
|
||||||
@@ -15,7 +15,7 @@ import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
|
|||||||
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
|
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
|
||||||
import { readCarriedInline } from '../opaque-carry.ts'
|
import { readCarriedInline } from '../opaque-carry.ts'
|
||||||
import { readDirectiveMark } from './directive-marks.ts'
|
import { readDirectiveMark } from './directive-marks.ts'
|
||||||
import { readInlineDirective } from '../directive-syntax.ts'
|
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
|
||||||
import { readInlineDirectiveNode } from './directive-nodes.ts'
|
import { readInlineDirectiveNode } from './directive-nodes.ts'
|
||||||
import { readTextDirective } from '../text-directive.ts'
|
import { readTextDirective } from '../text-directive.ts'
|
||||||
|
|
||||||
@@ -45,6 +45,7 @@ type Scan = {
|
|||||||
pending: string
|
pending: string
|
||||||
pieces: Piece[]
|
pieces: Piece[]
|
||||||
source: string
|
source: string
|
||||||
|
spans: NestedSpans
|
||||||
}
|
}
|
||||||
|
|
||||||
type SlotContent = { carry: boolean; nodes: AdfNode[] }
|
type SlotContent = { carry: boolean; nodes: AdfNode[] }
|
||||||
@@ -54,11 +55,11 @@ const imageAlone = 'an image fits only as a paragraph of its own: this one sits
|
|||||||
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
|
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
|
||||||
|
|
||||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> {
|
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> {
|
||||||
return parseInline(source, definitions, path, container)
|
return parseInline(source, definitions, path, container, noSpans)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined): Result<InlineContent> {
|
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans): Result<InlineContent> {
|
||||||
const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source }
|
const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
|
||||||
let index = 0
|
let index = 0
|
||||||
while (index < source.length) {
|
while (index < source.length) {
|
||||||
switch (source.charAt(index)) {
|
switch (source.charAt(index)) {
|
||||||
@@ -168,14 +169,20 @@ function openBracket(scan: Scan, index: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readDirective(scan: Scan, index: number): Result<number> | undefined {
|
function readDirective(scan: Scan, index: number): Result<number> | undefined {
|
||||||
|
const held = scan.spans.get(index)
|
||||||
|
if (held !== undefined) return pushDirective(scan, held, index)
|
||||||
const directive = readInlineDirective(scan.source, index)
|
const directive = readInlineDirective(scan.source, index)
|
||||||
if (directive === undefined) return undefined
|
if (directive === undefined) return undefined
|
||||||
if (directive.fault !== undefined) return faulted(directive.fault, scan.path)
|
if (directive.fault !== undefined) return faulted(directive.fault, scan.path)
|
||||||
const piece = directivePiece(scan, directive.value, index)
|
return pushDirective(scan, directive.value, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushDirective(scan: Scan, span: DirectiveSpan, index: number): Result<number> {
|
||||||
|
const piece = directivePiece(scan, span, index)
|
||||||
if (!piece.ok) return piece
|
if (!piece.ok) return piece
|
||||||
flush(scan, false)
|
flush(scan, false)
|
||||||
scan.pieces.push(piece.value)
|
scan.pieces.push(piece.value)
|
||||||
return success(index + directive.value.length)
|
return success(index + span.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<Piece> {
|
function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<Piece> {
|
||||||
@@ -187,7 +194,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
|
|||||||
const text = readTextDirective(span)
|
const text = readTextDirective(span)
|
||||||
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
|
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
|
||||||
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
|
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
|
||||||
const slot = slotContent(scan, span.content)
|
const slot = slotContent(scan, span)
|
||||||
if (!slot.ok) return slot
|
if (!slot.ok) return slot
|
||||||
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
|
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
|
||||||
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
|
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
|
||||||
@@ -216,9 +223,9 @@ function refuseSpellableLink(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function slotContent(scan: Scan, content: string | undefined): Result<SlotContent | undefined> {
|
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
|
||||||
if (content === undefined) return success(undefined)
|
if (span.content === undefined) return success(undefined)
|
||||||
const parsed = parseInline(content, scan.definitions, scan.path, undefined)
|
const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans)
|
||||||
if (!parsed.ok) return parsed
|
if (!parsed.ok) return parsed
|
||||||
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
||||||
return success(parsed.value)
|
return success(parsed.value)
|
||||||
|
|||||||
@@ -504,6 +504,48 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
|
|||||||
counting the directive form once for doubling the parser's frames per level.
|
counting the directive form once for doubling the parser's frames per level.
|
||||||
**Measured** (2026-09-18): `adfDocumentFault` walks a 9 MB document in 52 ms against 314 ms
|
**Measured** (2026-09-18): `adfDocumentFault` walks a 9 MB document in 52 ms against 314 ms
|
||||||
for the emit, so its two walks stay parted.
|
for the emit, so its two walks stay parted.
|
||||||
|
- [x] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks
|
||||||
|
its run from every start position, so an interior whitespace run costs quadratic time rather
|
||||||
|
than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk
|
||||||
|
replaced the regex. The sites the same sweep did not reach: `normalizeLabel` in
|
||||||
|
`link-syntax.ts`, whose shortcut-reference input is `scan.source.slice(...)` rather than the
|
||||||
|
999-capped `readLabel` value, and `carryEdges` in `emit/inline-line.ts`. A third of another
|
||||||
|
shape joins them: `readNestedDirective` restarts its depth counter per level, so each parse
|
||||||
|
level re-scans the region below it and nested inline directives cost O(depth × content),
|
||||||
|
bounded by the 500-level guard. A fourth predates 12c: the list-item walk re-scans the rest
|
||||||
|
of a line once per item level — `isThematicBreak` in `containerStart` on an opener line,
|
||||||
|
`isBlankLine` and `leadingColumns` in `continuesContainer` on a continuation line, and a
|
||||||
|
blank line continues every open item without consuming input; 30000 nested items take 4.4s
|
||||||
|
at 59 KB (the stability-reviewer, 2026-09-16). §11's scanning rule is the whole argument; the
|
||||||
|
pipeline persona feeds documents nobody typed. A fifth is a throw rather than a cost:
|
||||||
|
`adfDocumentFault` pushes a node's content with a spread, so past about 125k sibling nodes
|
||||||
|
the guard throws a `RangeError` where §11 owes a `Result` (the stability-reviewer and the
|
||||||
|
maintainer, 2026-09-18).
|
||||||
|
**Settled** (the maintainer, 2026-09-18): the five sites land in one PR rather than split
|
||||||
|
into sub-items, and a behaviour-preserving cost fix is accepted on the suite staying green
|
||||||
|
with no fixture output changed, plus the measurement below — §14 promises no figure, so
|
||||||
|
nothing times the gate. The guard's spread is the one behavioural fix and carries a test.
|
||||||
|
**Corrected** (2026-09-18): the entry filed two sites in `emit/inline-line.ts` on 2026-09-01
|
||||||
|
and the file has changed since — `tryImageLine`'s alternation measures linear (3.4 / 1.9 /
|
||||||
|
5.3 ms over 10k / 20k / 40k spaces), leaving `carryEdges`' trailing trim the only one.
|
||||||
|
**Measured** (2026-09-18), each at the size its filing named: `normalizeLabel` 1026 ms → 5 ms
|
||||||
|
at 40k interior spaces, `carryEdges` 1024 ms → 7 ms (its heading path 978 ms → 5 ms),
|
||||||
|
`readNestedDirective` 434 ms → 10 ms at 397 kB and 200 levels, the list-item walk 4196 ms →
|
||||||
|
39 ms at 30000 items, and the document guard a `RangeError` → 42 ms at 200k siblings under
|
||||||
|
one node. The list-item walk's mixed-marker shape, which the fix had to answer too, reads
|
||||||
|
38 ms where the tail scan alone would have left it quadratic.
|
||||||
|
The sixth site the sweep found went to 18 rather than landing here (the maintainer,
|
||||||
|
2026-09-18).
|
||||||
|
**Left as is** (the stability-reviewer, 2026-09-18): of the list-item walk's three re-scans
|
||||||
|
only `containerStart`'s is fixed. `continuesContainer`'s pair costs the same either way — 400
|
||||||
|
levels at 627 kB read 469 ms before and 448 ms after, linear in the line count and only
|
||||||
|
mildly superlinear in a depth the 500-level guard bounds — so it is measured and left rather
|
||||||
|
than made an item.
|
||||||
|
**Widened** (the systems-architect, 2026-09-18): the guard's spread was a class rather than a
|
||||||
|
site, and two more threw out of the public API — `readIndentedCodeLine` releasing the blank
|
||||||
|
lines an indented code block held (200k of them at 200 kB), and `emitRun` joining a mark
|
||||||
|
run's segments (200k nodes under one mark). Both fixed here with the same loop and a test
|
||||||
|
each, and §11 gained the rule so the spelling cannot walk back in.
|
||||||
- [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being
|
- [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being
|
||||||
the published identity: `package.json` `name` and `repository`, the Gitea repo and its
|
the published identity: `package.json` `name` and `repository`, the Gitea repo and its
|
||||||
remote, the README title, §6's published-as line, the checkout directory.
|
remote, the README title, §6's published-as line, the checkout directory.
|
||||||
|
|||||||
@@ -3,13 +3,32 @@
|
|||||||
The plan. Design questions are settled in `AGENTS.md`; remaining spec detail is settled at its own
|
The plan. Design questions are settled in `AGENTS.md`; remaining spec detail is settled at its own
|
||||||
milestone. A done item shrinks to its title here; its full text moves to `todo-history.md`.
|
milestone. A done item shrinks to its title here; its full text moves to `todo-history.md`.
|
||||||
|
|
||||||
|
## Next session
|
||||||
|
|
||||||
|
Start a session with: `Read AGENTS.md and todo.md, then do what todo.md's "Next session" says.`
|
||||||
|
|
||||||
|
1. The first unchecked item in shipping order, per AGENTS.md §15 — or, where that item has no
|
||||||
|
release, the planning chunk §15 describes.
|
||||||
|
2. In flight: nothing.
|
||||||
|
3. `git fetch origin` and branch off `origin/main`, not the worktree left behind: `main` moved
|
||||||
|
under 4c mid-chunk and the branch needed a rebase before it could merge.
|
||||||
|
4. Before stopping, rewrite this section: the in-flight line, and the prompt itself wherever the
|
||||||
|
session found it wrong or short.
|
||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b, 4c, 14, 15, 16, 10, 5g → `0.2.0`;
|
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b,
|
||||||
4d, 5f → `0.2.1`; 6, 7 → `0.3.0`; 9, 17 → TBD; 5e last.
|
4c, 14, 15, 16, 18, 4d, 17, 10, 6, 7, 5f, 5g → `0.2.0`; 8, 9 → TBD; 5e last.
|
||||||
The numbering is the order the work was planned in, not the order it ships. `0.2.0`'s order is settled
|
The numbering is the order the work was planned in, not the order it ships. Everything known and
|
||||||
(the maintainer, 2026-09-13): 11 makes the tables 4 generates from answer to Atlassian's schema, 4
|
shaped ships in one release rather than a string of them: nothing waits on a version, and no
|
||||||
proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c change.
|
consumer is served by the churn (the maintainer, 2026-09-18). So `0.2.0` completes §1's three
|
||||||
|
formats, and `0.2.1` and `0.3.0` are gone. `8` and `9` stay out as the two goals nothing has shaped
|
||||||
|
yet. `0.2.0`'s order is settled (the maintainer, 2026-09-13, extended 2026-09-18): 11 makes the
|
||||||
|
tables 4 generates from answer to Atlassian's schema, 4 proves 12, 13 spells 11's gaps in 12's
|
||||||
|
grammar, and 12 rewrites code 4b and 4c change; then 14 moves the files 15, 16 and 10 edit and HTML
|
||||||
|
is written against that layout, 4d marks the gate legs before 17 adds one, 17 puts the complexity
|
||||||
|
guardrail under the largest body of new code, and 5f and 5g read last because 7 is what changes the
|
||||||
|
bundle size and the tagline.
|
||||||
|
|
||||||
- [x] **0 — Scaffold.**
|
- [x] **0 — Scaffold.**
|
||||||
- [x] **1a — The directive grammar.**
|
- [x] **1a — The directive grammar.**
|
||||||
@@ -49,33 +68,8 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
|
|||||||
- [x] **4.3 — The markdown property.**
|
- [x] **4.3 — The markdown property.**
|
||||||
- [x] **4.4 — The real payloads.**
|
- [x] **4.4 — The real payloads.**
|
||||||
- [x] **4b — The block walk's retry (`0.2.0`).**
|
- [x] **4b — The block walk's retry (`0.2.0`).**
|
||||||
- [ ] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks
|
- [x] **4c — The scanning rule's remaining sites (`0.2.0`).**
|
||||||
its run from every start position, so an interior whitespace run costs quadratic time rather
|
- [ ] **4d — What the gate says while it runs (`0.2.0`).** `ci.sh` runs nine legs and announces
|
||||||
than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk
|
|
||||||
replaced the regex. Three sites the same sweep did not reach: `normalizeLabel` in
|
|
||||||
`link-syntax.ts`, whose shortcut-reference input is `scan.source.slice(...)` rather than the
|
|
||||||
999-capped `readLabel` value, and two in `emit/inline-line.ts`. The fix is the one 3h used —
|
|
||||||
an index walk, `trimTrailingSpace` where the ends match. A fourth of another shape joins
|
|
||||||
them: `readNestedDirective` restarts its depth counter per level, so each parse level
|
|
||||||
re-scans the region below it and nested inline directives cost O(depth × content) — 3f's
|
|
||||||
cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level
|
|
||||||
guard. A fifth predates 12c: the list-item walk re-scans the rest of a line once per item
|
|
||||||
level — `isThematicBreak` in `containerStart` on an opener line, `isBlankLine` and
|
|
||||||
`leadingColumns` in `continuesContainer` on a continuation line, and a blank line
|
|
||||||
continues every open item without consuming input; 30000 nested items take 4.4s at 59 KB
|
|
||||||
(the stability-reviewer, 2026-09-16). §11's scanning rule is
|
|
||||||
the whole argument; the pipeline persona feeds documents nobody typed.
|
|
||||||
`readDirectiveContent`'s scan splits into named steps with that fix rather than keeping its
|
|
||||||
complexity (the maintainer, 2026-09-16). A sixth 4b leaves behind: the parser asks
|
|
||||||
`commonMarkSpelling` at every directive-spelled list it reads, and the answer spells the whole
|
|
||||||
subtree below, itself quadratic in the depth left, so nested directive lists cost about the
|
|
||||||
cube of their depth — 250 rule-first levels parse in 1.3 s at 16.5 kB, 1.5 MB of that shape
|
|
||||||
at 250 levels in 0.6 s — bounded by the depth guard like `readNestedDirective` (the
|
|
||||||
maintainer, 2026-09-18). A seventh is a throw rather than a cost: `adfDocumentFault` pushes a
|
|
||||||
node's content with a spread, so past about 125k sibling nodes the guard throws a
|
|
||||||
`RangeError` where §11 owes a `Result` — a loop over the content closes it (the
|
|
||||||
stability-reviewer and the maintainer, 2026-09-18).
|
|
||||||
- [ ] **4d — What the gate says while it runs (`0.2.1`).** `ci.sh` runs nine legs and announces
|
|
||||||
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
|
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
|
||||||
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its
|
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its
|
||||||
own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and
|
own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and
|
||||||
@@ -102,7 +96,7 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
|
|||||||
and is the trade to weigh rather than discover on a red release run.
|
and is the trade to weigh rather than discover on a red release run.
|
||||||
**Settled** (the maintainer, 2026-09-13): last of the known work, clear of `0.2.0`, placed
|
**Settled** (the maintainer, 2026-09-13): last of the known work, clear of `0.2.0`, placed
|
||||||
there knowing the cutoff may land before `0.2.0` ships.
|
there knowing the cutoff may land before `0.2.0` ships.
|
||||||
- [ ] **5f — Publish the bundle size (`0.2.1`).** Measure the shipped artifact and put the number in the
|
- [ ] **5f — Publish the bundle size (`0.2.0`).** Measure the shipped artifact and put the number in the
|
||||||
README, kept honest by the release pipeline rather than by a human re-reading it. The
|
README, kept honest by the release pipeline rather than by a human re-reading it. The
|
||||||
quantity is what a consumer downloads and loads: the tarball `npm pack` produces, its
|
quantity is what a consumer downloads and loads: the tarball `npm pack` produces, its
|
||||||
unpacked `dist`, and the built JavaScript minified + gzipped — the figure the competitors
|
unpacked `dist`, and the built JavaScript minified + gzipped — the figure the competitors
|
||||||
@@ -120,10 +114,10 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
|
|||||||
"why" note left. The top follows the package-README order: an npm version badge and the Gitea
|
"why" note left. The top follows the package-README order: an npm version badge and the Gitea
|
||||||
Actions badge, a tagline that is also `package.json`'s `description`, a feature list and a
|
Actions badge, a tagline that is also `package.json`'s `description`, a feature list and a
|
||||||
one-line table of contents, then install and the shortest runnable example; a table of
|
one-line table of contents, then install and the shortest runnable example; a table of
|
||||||
everything exported sits near the bottom. The HTML directions are one aside line under the API
|
everything exported sits near the bottom. The HTML directions were to stay an aside until a
|
||||||
until `0.3.0` ships them, the `// 0.3.0` signatures and the `0.3.0` guarantee going until then.
|
later release shipped them; 7 now ships in this one and reads ahead of this item, so the
|
||||||
The tagline and `description` read "Lossless conversion between Atlassian Document Format and
|
README documents HTML as it documents markdown, the tagline and `description` naming both
|
||||||
extended markdown" until 7 restores HTML.
|
(the maintainer, 2026-09-13, revised 2026-09-18).
|
||||||
- [x] **5a — Rename to `@larvit/adf-codec`.**
|
- [x] **5a — Rename to `@larvit/adf-codec`.**
|
||||||
- [x] **5b — The consumer's error surface.**
|
- [x] **5b — The consumer's error surface.**
|
||||||
- [x] **5b1 — The error's source position.**
|
- [x] **5b1 — The error's source position.**
|
||||||
@@ -132,11 +126,11 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
|
|||||||
- [x] **5b4 — The README's consumer surface.**
|
- [x] **5b4 — The README's consumer surface.**
|
||||||
- [x] **5c — The build and the release pipeline.**
|
- [x] **5c — The build and the release pipeline.**
|
||||||
- [x] **5d — The browser leg.**
|
- [x] **5d — The browser leg.**
|
||||||
- [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity
|
- [ ] **6 — The HTML dialect spec (`0.2.0`).** Element-by-element mapping, the `data-*` fidelity
|
||||||
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
|
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
|
||||||
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /
|
- [ ] **7 — HTML, the third format (`0.2.0`).** `adfToHtml`, `htmlToAdf`, the composed
|
||||||
`htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from here (§10). The
|
`markdownToHtml` / `htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from
|
||||||
README's tagline and `package.json`'s `description` regain HTML (5g).
|
here (§10). The README's tagline and `package.json`'s `description` regain HTML (5g).
|
||||||
- [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists.
|
- [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists.
|
||||||
- [ ] **9 — The online sandbox.** A web page with two textboxes converting back and forth between ADF and markdown, powered by the library's browser build.
|
- [ ] **9 — The online sandbox.** A web page with two textboxes converting back and forth between ADF and markdown, powered by the library's browser build.
|
||||||
- [ ] **10 — Lossy conversion (`0.2.0`).** Markdown other tools render readably, to and from ADF,
|
- [ ] **10 — Lossy conversion (`0.2.0`).** Markdown other tools render readably, to and from ADF,
|
||||||
@@ -231,10 +225,30 @@ proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c c
|
|||||||
brackets stay literal text, CommonMark's rule that no link holds another — rather than
|
brackets stay literal text, CommonMark's rule that no link holds another — rather than
|
||||||
dropping the outer link silently as `closeLink`'s `applyMark` does today, with a normalization
|
dropping the outer link silently as `closeLink`'s `applyMark` does today, with a normalization
|
||||||
fixture per shape (the stability-reviewer, 2026-09-16; the maintainer, 2026-09-17).
|
fixture per shape (the stability-reviewer, 2026-09-16; the maintainer, 2026-09-17).
|
||||||
- [ ] **17 — A machine-enforced size guardrail.** Add a per-function complexity check to the gate —
|
- [ ] **17 — A machine-enforced size guardrail (`0.2.0`).** Add a per-function complexity check to
|
||||||
branch count or size — so the fits-in-your-head guardrail fails the build rather than
|
the gate — branch count or size — so the fits-in-your-head guardrail fails the build rather
|
||||||
waiting for a review to catch it (the systems-architect, 2026-09-16); placed after `0.3.0`
|
than waiting for a review to catch it (the systems-architect, 2026-09-16). It reads ahead of
|
||||||
(the maintainer, 2026-09-17).
|
6, 7 and 10 so the largest body of new code is written under it, which is also what decides
|
||||||
|
the threshold: today's worst is `readDirectiveContent`, 27 lines and about 12 decision points
|
||||||
|
over four concerns in one loop — escape, code span, nested directive, bracket balance — which
|
||||||
|
4c left half-split and this item either passes or forces apart (the systems-architect and the
|
||||||
|
maintainer, 2026-09-18).
|
||||||
|
- [ ] **18 — The subtree the directive spelling asks about (`0.2.0`).** The parser asks
|
||||||
|
`commonMarkSpelling` at every directive-spelled block and the answer emits the whole subtree
|
||||||
|
below, so a node at depth d is spelled d times: three nested rule-first directive lists cost
|
||||||
|
18 asks over 10 nodes, and 250 levels parse in 1.2 s at 16.4 kB, 4.9 s at 261 kB with a
|
||||||
|
kilobyte of content per level. The depth guard bounds the levels at about 250, never the
|
||||||
|
content, so this is the pipeline persona's hang on an input nobody typed (§11). Keeping each
|
||||||
|
child's emitted result for its parent's ask is not a straight handover: the same node object
|
||||||
|
is asked at different depths — 4, 3 and 2 for the innermost list of three — because the
|
||||||
|
parser counts a list and its item as two levels where the emitter's readable list counts one
|
||||||
|
(4b), and `headroom` is that guard's slack. The parts that survive the measurement: the paths
|
||||||
|
agree, `text` and `spelling` carry no depth, `headroom` is affine in it, and the parser asks
|
||||||
|
first at the deepest of them, so a kept result rebases by the difference. Either rebase and
|
||||||
|
record that argument in `AGENTS.md`, or give both directions one list accounting so a node
|
||||||
|
has one depth and nothing needs rebasing — which reopens 4b. A single post-build walk was
|
||||||
|
rejected: it reports the outer offender where the build reports the inner one (the
|
||||||
|
maintainer, 2026-09-18).
|
||||||
|
|
||||||
## The ADF inventory to cover
|
## The ADF inventory to cover
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user