mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
Answer principles round 1: 0 🔴, 3 🟡 (0 misread); row section field, one heading-level predicate, items 171-174 filed
This commit is contained in:
@@ -83,12 +83,18 @@ export const blockNodes = {
|
||||
taskList: { attributes: localIdAttributes, contentModel: 'block' },
|
||||
} satisfies Readonly<Record<string, BlockNodeModel>>
|
||||
|
||||
export const headingLevels: readonly number[] = [1, 2, 3, 4, 5, 6]
|
||||
|
||||
export type BlockType = keyof typeof blockNodes
|
||||
|
||||
export function blockNodeModel(type: string): BlockNodeModel | undefined {
|
||||
return isBlockType(type) ? blockNodes[type] : undefined
|
||||
}
|
||||
|
||||
export function isHeadingLevel(level: unknown): level is number {
|
||||
return typeof level === 'number' && headingLevels.includes(level)
|
||||
}
|
||||
|
||||
export function isTaskItem(node: AdfNode | undefined): boolean {
|
||||
return node?.type === 'taskItem' || node?.type === 'blockTaskItem'
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { headingLevels, isHeadingLevel } from '../adf/block-nodes.ts'
|
||||
|
||||
export type ContainerType = 'blockquote' | 'bulletList' | 'orderedList'
|
||||
|
||||
// The element of each node whose blocks, or list items, stand a line each inside it.
|
||||
export const containerElements: Readonly<Record<ContainerType, string>> = { blockquote: 'blockquote', bulletList: 'ul', orderedList: 'ol' }
|
||||
const headingLevels: readonly unknown[] = [1, 2, 3, 4, 5, 6]
|
||||
export const headingElements: readonly string[] = headingLevels.map((level) => `h${level}`)
|
||||
|
||||
// `undefined` where the level is none of 1 to 6, so the heading rides the carry.
|
||||
export function headingElement(level: JsonValue | undefined): string | undefined {
|
||||
return headingLevels.includes(level) ? `h${level}` : undefined
|
||||
return isHeadingLevel(level) ? `h${level}` : undefined
|
||||
}
|
||||
|
||||
// The level of `<h1>` to `<h6>`, 0 for any other element.
|
||||
|
||||
@@ -28,8 +28,13 @@ type BlockReader = (html: string, tag: StartTag, place: Place, open: Open) => Re
|
||||
|
||||
type BlockStep = { end: number; kind: 'node'; node: AdfNode } | { end: number; kind: 'opened'; open: Open }
|
||||
|
||||
// A container whose blocks, items, rows or cells are being read: `node` holds what its opening tag gave, `depth` is its children's as `adfToHtml` counts it, and `closer` ends it. An open table has kind `rows`, and its closer turns from `headCloser` to `bodyCloser` at `</thead>`.
|
||||
type Open = { closer: string; content: AdfNode[]; depth: number; kind: 'blocks' | 'item' | 'list' | 'row' | 'rows'; node: AdfNode; place: Place | undefined }
|
||||
// A container whose blocks, items, rows or cells are being read: `node` holds what its opening tag gave, `depth` is its children's as `adfToHtml` counts it, and `closer` ends it. An open table has kind `rows` and no `closer`: `section` names the one its rows stand in, and `closerOf` derives its closer from it.
|
||||
type Open = { content: AdfNode[]; depth: number; node: AdfNode; place: Place | undefined } & ({ closer: string; kind: 'blocks' | 'item' | 'list' | 'row' } | { kind: 'rows'; section: TableSection })
|
||||
|
||||
// A row's step names the section the table's rows stand in from that row on.
|
||||
type RowStep = BlockStep & { section: TableSection }
|
||||
|
||||
type TableSection = 'body' | 'head'
|
||||
|
||||
// No element name opens both a block and an inline node, so the name alone tells an item's line from its next block.
|
||||
const blockReaders: ReadonlyMap<string, BlockReader> = new Map([
|
||||
@@ -90,13 +95,14 @@ function readDocumentCarry(html: string): Result<AdfDocument, ParseError> | unde
|
||||
function readBlocks(html: string): Result<AdfDocument, ParseError> {
|
||||
const root: Open = { closer: '', content: [], depth: 0, kind: 'blocks', node: { type: 'doc' }, place: undefined }
|
||||
const opened: Open[] = []
|
||||
let open = root
|
||||
let open: Open = root
|
||||
let index = 0
|
||||
for (;;) {
|
||||
const place = { index: open.content.length, parent: open.place }
|
||||
if (open.depth > largestNesting) return refused('unsupported-nesting-depth', `blocks nest deeper than ${largestNesting} levels`, html, index, place)
|
||||
const step = readStep(html, index, place, open)
|
||||
if (!step.ok) return step
|
||||
if (open.kind === 'rows' && 'section' in step.value) open.section = step.value.section
|
||||
index = step.value.end
|
||||
if (step.value.kind === 'opened') {
|
||||
opened.push(open)
|
||||
@@ -104,12 +110,12 @@ function readBlocks(html: string): Result<AdfDocument, ParseError> {
|
||||
continue
|
||||
}
|
||||
open.content.push(step.value.node)
|
||||
while (open !== root && html.startsWith(open.closer, index)) {
|
||||
index += open.closer.length
|
||||
while (open !== root && html.startsWith(closerOf(open), index)) {
|
||||
index += closerOf(open).length
|
||||
const closed: AdfNode = { ...open.node, content: open.content }
|
||||
const closedPlace = open.place
|
||||
open = opened.pop() ?? root
|
||||
if (open.kind === 'rows' && open.content.length === 0 && isHeaderRow(closed) !== (open.closer === headCloser)) return firstRowRefusal(html, index, closedPlace)
|
||||
if (open.kind === 'rows' && open.content.length === 0 && isHeaderRow(closed) !== (open.section === 'head')) return firstRowRefusal(html, index, closedPlace)
|
||||
open.content.push(closed)
|
||||
}
|
||||
if (open.kind === 'row') continue
|
||||
@@ -125,7 +131,12 @@ function partedRefusal<T>(html: string, end: number, place: Place | undefined):
|
||||
return refused('unmappable-html', `a line feed parts each block from the next, and ends the HTML: ${foundAt(html, at)}`, html, at, place)
|
||||
}
|
||||
|
||||
function readStep(html: string, index: number, place: Place, open: Open): Result<BlockStep, ParseError> {
|
||||
function closerOf(open: Open): string {
|
||||
if (open.kind !== 'rows') return open.closer
|
||||
return open.section === 'head' ? headCloser : bodyCloser
|
||||
}
|
||||
|
||||
function readStep(html: string, index: number, place: Place, open: Open): Result<BlockStep | RowStep, ParseError> {
|
||||
if (open.kind === 'list') return readItem(html, index, place, open.depth)
|
||||
if (open.kind === 'rows') return readRow(html, index, place, open)
|
||||
return open.kind === 'row' ? readCell(html, index, place, open.depth) : readBlock(html, index, place, open)
|
||||
@@ -259,15 +270,15 @@ function readTable(html: string, tag: StartTag, place: Place, open: Open): Resul
|
||||
if (html.startsWith('</table>', tag.end)) return success({ end: tag.end + '</table>'.length, kind: 'node', node })
|
||||
const headed = html.startsWith('\n<thead>\n', tag.end)
|
||||
if (!headed && !html.startsWith('\n<tbody>\n', tag.end)) return refused('unmappable-html', `<table> holds </table> straight after it, or a line feed and <thead> or <tbody> on a line of its own: ${foundAt(html, tag.end)}`, html, tag.end, place)
|
||||
return success({ end: tag.end + '\n<thead>\n'.length, kind: 'opened', open: { closer: headed ? headCloser : bodyCloser, content: [], depth: open.depth + 1, kind: 'rows', node, place } })
|
||||
return success({ end: tag.end + '\n<thead>\n'.length, kind: 'opened', open: { content: [], depth: open.depth + 1, kind: 'rows', node, place, section: headed ? 'head' : 'body' } })
|
||||
}
|
||||
|
||||
// `<thead>` holds one row, so a second row stands past `</thead>`.
|
||||
function readRow(html: string, index: number, place: Place, table: Open): Result<BlockStep, ParseError> {
|
||||
const pastHead = table.closer === headCloser && table.content.length > 0
|
||||
function readRow(html: string, index: number, place: Place, table: Open & { kind: 'rows' }): Result<RowStep, ParseError> {
|
||||
const pastHead = table.section === 'head' && table.content.length > 0
|
||||
if (pastHead && !html.startsWith(headToBody, index)) return refused('unmappable-html', `<thead> holds one row, then </thead>: ${foundAt(html, index)}`, html, index, place)
|
||||
const start = pastHead ? index + headToBody.length : index
|
||||
if (pastHead) table.closer = bodyCloser
|
||||
const section = pastHead ? 'body' : table.section
|
||||
const tag = readStartTag(html, start)
|
||||
if (tag?.name !== 'tr') {
|
||||
const stop = misread(html, start, 'a table section holds <tr> rows, one to a line', 'no table row of the dialect')
|
||||
@@ -276,8 +287,8 @@ function readRow(html: string, index: number, place: Place, table: Open): Result
|
||||
const data = readDataAttributes(tag.attributes, tag.name, blockNodes.tableRow.attributes, [])
|
||||
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
|
||||
const node = built('tableRow', data.value, [])
|
||||
if (!html.startsWith('</tr>', tag.end)) return success({ end: tag.end, kind: 'opened', open: { closer: '</tr>', content: [], depth: table.depth + 1, kind: 'row', node, place } })
|
||||
return table.closer === headCloser ? firstRowRefusal(html, tag.end, place) : success({ end: tag.end + '</tr>'.length, kind: 'node', node })
|
||||
if (!html.startsWith('</tr>', tag.end)) return success({ end: tag.end, kind: 'opened', open: { closer: '</tr>', content: [], depth: table.depth + 1, kind: 'row', node, place }, section })
|
||||
return section === 'head' ? firstRowRefusal(html, tag.end, place) : success({ end: tag.end + '</tr>'.length, kind: 'node', node, section })
|
||||
}
|
||||
|
||||
function firstRowRefusal<T>(html: string, index: number, place: Place | undefined): Result<T, ParseError> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import type { WrittenFlavour } from '../flavour.ts'
|
||||
import { blockDirectiveForm, documentSpelling } from '../directive/block.ts'
|
||||
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
|
||||
import { blockNodeModel, blockNodes, isHeadingLevel } from '../../adf/block-nodes.ts'
|
||||
import { blockText, emptyLine, holdsBlankLine, joinedText, opensWithThematicBreak, prefixedText, quotedText, writtenText, type BlockText } from './block-text.ts'
|
||||
import { carriedBlock } from '../opaque-carry.ts'
|
||||
import { emitInlineLine } from './inline-line.ts'
|
||||
@@ -250,7 +250,7 @@ function fencedTexts(node: AdfNode): string[] | undefined {
|
||||
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: WrittenFlavour): Result<EmittedBlock> | undefined {
|
||||
if (!holdsOnlyAttributes(node, ['level'])) return undefined
|
||||
const level = nodeAttrs(node)['level']
|
||||
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
|
||||
if (!isHeadingLevel(level)) return undefined
|
||||
const hashes = '#'.repeat(level)
|
||||
const content = nodeContent(node)
|
||||
if (content.length === 0) return success(commonMarkText(blockText(hashes)))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts'
|
||||
import { blockNodeModel, isHeadingLevel, isTaskItem } from '../../adf/block-nodes.ts'
|
||||
import { centeredImage, externalMedia } from '../../adf/plain-image.ts'
|
||||
import { commonMarkSpelling, largestListMarker, spellingMemo, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
@@ -212,7 +212,7 @@ function withoutTrailingBlanks(text: string): string {
|
||||
|
||||
function reduceHeading(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const level = nodeAttrs(node)['level']
|
||||
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return paragraphOf(nodeContent(node), reduction)
|
||||
if (!isHeadingLevel(level)) return paragraphOf(nodeContent(node), reduction)
|
||||
const content = reduceInline(nodeContent(node), 'heading', reduction)
|
||||
return content.ok ? success([{ attrs: { level }, content: content.value, type: 'heading' }]) : content
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
`Bar = 0.1`
|
||||
|
||||
`Next ID = 171`
|
||||
`Next ID = 175`
|
||||
|
||||
## Items
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
| 169 | 0.4.0 | | owner | **Investigate `kovetskiy/mark` in depth and record in a report how its formats differ from ours, where the two projects overlap and how they compare.** | 1 | 1 | 1 | 1 | 3 | 3 by 4 | 6.3 |
|
||||
| 112 | 0.4.0 | | | **Pad a GFM pipe table's short rows with empty cells, read its body rows holding no pipe, and read a one-column table without leading pipes in `portableMarkdownToAdf`.** | 2 | 1 | 2 | 3 | 3 | 4 by 4 | 6.2 |
|
||||
| 80 | 0.4.0 | | | **Name the node's path and the attribute key when `not-an-adf-document` refuses a node inside the document.** | 2 | 1 | 2 | 3 | 3 | 6 by 4 | 6.0 |
|
||||
| 171 | 0.4.0 | | principle | **Assert the facts an error message must carry, never its whole sentence, in `markdown-to-adf.test.ts`, `document.test.ts` and `html-to-adf.test.ts`.** | 3 | 1 | 1 | 3 | 6 | code health by 3 | 4.4 |
|
||||
| 173 | 0.4.0 | | principle | **Keep each description `commonMarkSpellings` gives a refused block beside the emitter's spelling it describes.** | 2 | 1 | 1 | 2 | 4 | code health by 1 | 4.4 |
|
||||
| 81 | 0.4.0 | | | **Fail the gate unless the README's error tables list exactly the codes `ConvertErrorCode` holds.** | 1 | 1 | 1 | 1 | 2 | 3 by 2 | 4.2 |
|
||||
| 147 | 0.4.0 | | | **Ship `adfToPlainText`, writing a document's content with no markup, under a README goal for plain text.** | 2 | 2 | 1 | 3 | 4 | 3 by 4 | 4.2 |
|
||||
| 141 | 0.4.0 | | decision | **Write `strike` as HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element.** | 1 | 1 | 1 | 1 | 2 | 6 by 2 | 4.0 |
|
||||
@@ -34,9 +36,11 @@
|
||||
| 136 | 0.4.0 | | | **Write underline, text and background colours, subscript, superscript, alignment and indentation as HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 2 | 2.0 |
|
||||
| 134 | 0.4.0 | | decision | **Write panels, expands, task lists and decision lists as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 3 | 1.8 |
|
||||
| 46 | 0.4.0 | 134, 135, 136 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 2 | 1 | 3 | 4 | 3 by 3 | 3.2 |
|
||||
| 174 | 0.4.0 | | defect | **Bound the passes `trimmedEdges` takes by a constant, so a line of alternating code and emphasised blanks trims in linear time.** | 3 | 1 | 2 | 4 | 7 | 9 by 3 | 2.3 |
|
||||
| 51 | 0.4.0 | | | **Match a reference label to its definition under Unicode case folding.** | 2 | 1 | 2 | 3 | 4 | 4 by 2 | 2.1 |
|
||||
| 148 | 0.4.0 | | | **Ship conversion between ADF and Jira wiki markup both ways, under a README goal for wiki markup.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 |
|
||||
| 149 | 0.4.0 | | | **Read any HTML into ADF, in a call or option a developer panel picks, under a README goal for foreign HTML.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 |
|
||||
| 172 | 0.4.0 | | principle | **Build one test per reader or case in place of a loop inside a test body, in the 26 test files that loop.** | 3 | 1 | 1 | 3 | 8 | code health by 2 | 0.8 |
|
||||
| 77 | 0.4.0 | | decision | **Read the input once into a plain copy, so an accessor property or a throwing Proxy yields `not-an-adf-document` and every call returns a result.** | 3 | 1 | 3 | 5 | 4 | | 0.0 |
|
||||
| 107 | 0.4.0 | | decision | **Spell the block carry's JSON so its length stays linear in the node's JSON.** | 3 | 1 | 3 | 5 | 5 | | 0.0 |
|
||||
| 170 | 0.4.0 | | owner | **Bundle the packed tarball with webpack, esbuild, Rollup and Bun in the gate, failing on any bundler warning, and run each bundle's round-trip in Node.** | 3 | 1 | 1 | 3 | 5 | | 0.0 |
|
||||
@@ -210,6 +214,26 @@ with an empty `path`. An export tool told this about one document in thousands t
|
||||
document's whole tree. Give the node's path, and name its type and the attribute key as
|
||||
`attributeNestingMessage` does. Found by the product-owner review of items 66 and 67, 2026-10-05.
|
||||
|
||||
### 171. Assert the facts an error message must carry, never its whole sentence, in `markdown-to-adf.test.ts`, `document.test.ts` and `html-to-adf.test.ts`.
|
||||
|
||||
README → Audience says a message is free text that may change in any release, yet `content()` in
|
||||
`src/markdown/parse/markdown-to-adf.test.ts` folds a failure into `${code}: ${message}`, about 103
|
||||
assertions compare that whole sentence, and `document.test.ts` (7) and `html-to-adf.test.ts` (3)
|
||||
pin `message:` the same way. A rewording fails tests with no behaviour changed, while the facts
|
||||
§`message` and `path` requires (the violation, the escape named) are never asserted on their own.
|
||||
Assert `code`, `path`, `position` and `assert.match(message, …)` on the facts the text carries.
|
||||
Technical principle: "Pin wording only where the wording is the contract". Found by the principles
|
||||
audit of the release review of 0.3.0, 2026-11-08.
|
||||
|
||||
### 173. Keep each description `commonMarkSpellings` gives a refused block beside the emitter's spelling it describes.
|
||||
|
||||
`commonMarkSpellings` (`src/markdown/parse/markdown-to-adf.ts`) restates in prose the spellings
|
||||
`spellReadableBlock` (`src/markdown/emit/adf-to-markdown.ts`) owns, such as "each item behind `- `,
|
||||
or behind `* ` where …". A changed spelling leaves the refusal naming the old one, and a node
|
||||
missing from the map is refused naming no spelling. Put each description beside the spelling and
|
||||
export it through `emit/spelling-questions.ts`, the one import `parse/` may take from `emit/`.
|
||||
Technical principle: DRY. Found by the principles audit of the release review of 0.3.0, 2026-11-08.
|
||||
|
||||
### 81. Fail the gate unless the README's error tables list exactly the codes `ConvertErrorCode` holds.
|
||||
|
||||
`src/result.test.ts` checks the union against the call sites only, so a code added or removed
|
||||
@@ -260,6 +284,27 @@ or report the unminified gzip). The figure lands in README §The package beside
|
||||
dependencies" claim, and in a size column of the comparison table. Measured today,
|
||||
unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6 kB.
|
||||
|
||||
### 174. Bound the passes `trimmedEdges` takes by a constant, so a line of alternating code and emphasised blanks trims in linear time.
|
||||
|
||||
`trimmedEdges` (`src/markdown/portable/inline-reduction.ts`) repeats a pass until nothing changes, and
|
||||
each pass costs O(leaves). Leaves alternating `' '` marked code with `' '` marked em take
|
||||
floor(n/4) + 2 passes: 26, 251 and 2 501 at n = 100, 1 000 and 10 000, so the trim is quadratic,
|
||||
against goal 9. The same holds for `'x'` code beside `' '` em, and for `[link, code] 'x'` beside
|
||||
`[link, em] ' '`. The smallest line is six leaves `' '` marked code, em, code, em, code, em, which
|
||||
takes 3 passes. The passes in a random search of 20 000 short lines never went above 4.
|
||||
|
||||
The cause: under `spanned` grouping an em or link run crosses the code leaves, which can't hold
|
||||
it, so one run spans the line and only its two end leaves stand beside a neighbour outside it. Each
|
||||
pass strips em from those two, and the next pair is exposed. A single pass cannot take the fixpoint
|
||||
directly: every pass judges all leaves against one snapshot, and `edgeOutcome` (drop at a line edge
|
||||
while a run is shared, keep when not) isn't monotone. A hard break between `[link, em] ' '` and
|
||||
`[link, em] ' b'` trims to `b` today, but to a linked space and `b` if the first leaf's drop is seen
|
||||
first. Three ways forward: make the edge rule monotone, which changes output in edge cases and
|
||||
needs a decision and a writer panel; keep run extents under truncation in `runsSharedWithNext`
|
||||
(`emit/inline-runs.ts`), exact but large; or state the cost as an exception under goal 9. Count passes
|
||||
to measure, never time (§Cost fixes are measured, never timed). Related: item 42 trims blanks inside
|
||||
one leaf. Found by the principles audit of the release review of 0.3.0, 2026-11-08.
|
||||
|
||||
### 51. Match a reference label to its definition under Unicode case folding.
|
||||
|
||||
`link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec
|
||||
@@ -286,6 +331,17 @@ panel of the developer personas picks the API among those the goals allow: a par
|
||||
`htmlToAdf` or a separate call; Goal 8's "nothing to configure" and Goal 6.1's naming bear on the
|
||||
choice. Split into items once the API is picked.
|
||||
|
||||
### 172. Build one test per reader or case in place of a loop inside a test body, in the 26 test files that loop.
|
||||
|
||||
134 loops run several readers or cases inside one test body, for example
|
||||
`for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf])` at lines 97,
|
||||
279, 771, 917, 926, 962 and 1118 of `src/markdown/parse/markdown-to-adf.test.ts`; the most are in
|
||||
`adf-schema.test.ts` (18), `limits.test.ts` (12) and `portable-markdown-to-adf.test.ts` (10). The
|
||||
first failing reader stops the test and the failure never names the reader. Write
|
||||
`for (const read of readers) test(`… in ${read.name}`, …)` at the top level. Technical principle:
|
||||
"No loops or branches in a test body; parametrize the cases". Found by the principles audit of the
|
||||
release review of 0.3.0, 2026-11-08.
|
||||
|
||||
### 77. Read the input once into a plain copy, so an accessor property or a throwing Proxy yields `not-an-adf-document` and every call returns a result.
|
||||
|
||||
Lands with item 45, in one chunk: item 45's reader returns the copy. The maintainer's call,
|
||||
|
||||
Reference in New Issue
Block a user