diff --git a/.oxlintrc.json b/.oxlintrc.json index a9d4558..7898490 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -27,6 +27,12 @@ "no-restricted-imports": ["error", { "patterns": [{ "group": ["**/html/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }] } }, + { + "files": ["src/markdown/*.ts"], + "rules": { + "no-restricted-imports": ["error", { "patterns": [{ "group": ["**/emit/**", "**/html/**", "**/parse/**", "**/portable/**"], "message": "A root file of src/markdown/ is read by the writers and the readers and imports neither: docs/decisions.md §The source parts by ADF and format" }] }] + } + }, { "files": ["src/markdown/directive/**"], "rules": { @@ -36,7 +42,7 @@ { "files": ["src/markdown/emit/**"], "rules": { - "no-restricted-imports": ["error", { "patterns": [{ "group": ["**/html/**", "**/portable/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }] + "no-restricted-imports": ["error", { "patterns": [{ "group": ["**/html/**", "**/parse/**", "**/portable/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }] } }, { diff --git a/ci.sh b/ci.sh index d854f7b..bdb8f2f 100755 --- a/ci.sh +++ b/ci.sh @@ -13,12 +13,16 @@ src/adf/boundary-probe-html.ts ../html/emit/adf-to-html.ts src/html/boundary-probe.ts ../markdown/flavour.ts src/html/parse/boundary-probe.ts ../emit/adf-to-html.ts src/html/parse/boundary-probe-markdown.ts ../../markdown/flavour.ts +src/markdown/boundary-probe-emit.ts ./emit/adf-to-markdown.ts src/markdown/boundary-probe-html.ts ../html/emit/adf-to-html.ts +src/markdown/boundary-probe-parse.ts ./parse/blocks.ts +src/markdown/boundary-probe-portable.ts ./portable/conventions.ts src/markdown/directive/boundary-probe.ts ../parse/blocks.ts src/markdown/directive/boundary-probe-emit.ts ../emit/image.ts src/markdown/directive/boundary-probe-html.ts ../../html/emit/adf-to-html.ts src/markdown/emit/boundary-probe.ts ../portable/conventions.ts src/markdown/emit/boundary-probe-html.ts ../../html/emit/adf-to-html.ts +src/markdown/emit/boundary-probe-parse.ts ../parse/blocks.ts src/markdown/parse/boundary-probe.ts ../emit/adf-to-markdown.ts src/markdown/parse/boundary-probe-html.ts ../../html/emit/adf-to-html.ts src/markdown/parse/boundary-probe-portable.ts ../portable/conventions.ts" diff --git a/docs/decisions.md b/docs/decisions.md index 862899c..0f60a4a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1175,7 +1175,7 @@ is the markdown flavour's choice, not ADF's. 2026-08-27, placement 2026-09-18, `adf/`'s bar 2026-09-21, `plain/` 2026-10-03, the import boundaries' lint 2026-10-22, the maintainer; `parse/` and `emit/` free of `portable/`, -2026-10-22. Goal 2. Valid while each format has a reader and a writer through ADF. +2026-10-22; the root's imports and `emit/` free of `parse/`, 2026-11-10. Goal 2. Valid while each format has a reader and a writer through ADF. `src/adf/` holds ADF's own knowledge, imports no format, and is where a construct both formats read lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a content @@ -1202,10 +1202,16 @@ whether a list marker interrupts a paragraph — is one function there too, neve direction, however conservative the copy would be. Where the rule is the emitter's own choice, input consults it rather than restating it, so no fixture the emitter writes can be refused, and a spelling the emitter refuses gives its own error rather than a second name for it. That consulting -is the only import `parse/` takes from `emit/`, through `emit/spelling-questions.ts`. +is the only import `parse/` takes from `emit/`, through `emit/spelling-questions.ts`. The root +imports neither `emit/`, `parse/` nor `portable/`, and `emit/` does not import `parse/`. The scan in +`inline-content.ts` reports whether a spellable link opens the paragraph, and +`parse/inline-content.ts` alone asks the writer what that link needs. A read-back (`readLineBack`) +never asks the writer: the writer calls it as it spells a line, so a read-back that asked would +recurse without bound (§Nothing recurses unbounded). `.oxlintrc.json` enforces that limit for `markdown/parse/`, keeps `html/parse/` from importing -`html/emit/`, keeps markdown's `parse/` and `emit/` from importing `portable/`, keeps +`html/emit/`, keeps markdown's `parse/` and `emit/` from importing `portable/`, keeps the root's files from +importing `emit/`, `parse/` and `portable/`, keeps `emit/` from importing `parse/`, keeps `markdown/directive/` from importing `emit/` or `parse/`, keeps `markdown/` and `html/` from importing each other, and keeps `adf/` from importing either, as `no-restricted-imports` overrides. A later override replaces an earlier one's rule, so each subdirectory's override repeats its diff --git a/src/markdown/inline-content.test.ts b/src/markdown/inline-content.test.ts new file mode 100644 index 0000000..ae7a536 --- /dev/null +++ b/src/markdown/inline-content.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { AdfNode } from '../adf/document.ts' +import type { Result } from '../result.ts' +import type { InlineContent } from './inline-content.ts' +import { parseInlineContent } from './inline-content.ts' + +function read(source: string): Result { + return parseInlineContent(source, new Map(), [], 'paragraph', 'lossless') +} + +function nodes(result: Result): AdfNode[] | string { + return result.ok ? result.value.nodes : result.error.code +} + +test('reports a directive link opening the line that CommonMark could spell', () => { + const opening = read('!adf:link[a]{href="/u"}') + assert.equal(opening.ok && opening.value.openingSpellableLink, true) + assert.deepEqual(nodes(opening), [{ marks: [{ attrs: { href: '/u' }, type: 'link' }], text: 'a', type: 'text' }]) +}) + +test('reports no spellable opening link where the line opens with text', () => { + const text = read('See a') + assert.equal(text.ok && text.value.openingSpellableLink, false) +}) + +test('reports the opening link whose CommonMark spelling would open a link definition, leaving the writer to answer', () => { + const definition = read('!adf:link[`]: a`]{href="/u"}') + assert.equal(definition.ok && definition.value.openingSpellableLink, true) +}) + +test('still refuses a spellable directive link past the opening of the line', () => { + assert.equal(nodes(read('See !adf:link[a]{href="/u"}.')), 'unsupported-node-shape') +}) diff --git a/src/markdown/inline-content.ts b/src/markdown/inline-content.ts index ae1faa2..28006f2 100644 --- a/src/markdown/inline-content.ts +++ b/src/markdown/inline-content.ts @@ -26,6 +26,9 @@ import { readTextDirective } from './directive/text.ts' // Only a `paragraph` block takes `image`, the `mediaSingle` a lone image builds; every other container takes `nodes`, where the image is its linked alt text. export type InlineContent = { image?: AdfNode; nodes: AdfNode[] } +// `openingSpellableLink`: a directive link opens the paragraph that CommonMark could spell; `parse/` asks the writer whether the line still needs the directive. +export type OutermostContent = InlineContent & { openingSpellableLink: boolean } + // The text break leaf: it holds no marks, and stands between the nodes it parts until the outermost content checks and drops it. type TextBreak = { kind: 'textBreak' } @@ -114,26 +117,22 @@ const hreflessLink = 'the link mark spells its href: this one spells none' const linkInLink = 'no link wraps a link: the [content] this one marks already holds one' const noDefinitions: LinkDefinitions = new Map() const unlinkable = 'the text of this link holds only nodes that take no link: write the link around text beside them' -const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or ' +export const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or ' const textBreak: TextBreak = { kind: 'textBreak' } -// The outermost content: only here does a text break see both its neighbours. The writers read lines back through this file, so it takes the emitter's `openingLinkTakesDirective` as an argument and imports nothing from `emit/`. -export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour, openingLinkTakesDirective: (nodes: readonly AdfNode[], path: ConvertErrorPath) => Result): Result { +// The outermost content: only here does a text break see both its neighbours. +export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result { const scan = freshScan(source, { definitions, path, reading: undefined, targets: targetMemo(source) }, { claims: flavourClaims[flavour], container, inLink: false, offset: 0, spans: noSpans }) const scanned = scanInline(scan) if (!scanned.ok) return scanned const nodes = partText(contentNodes(scanned.value.pieces), scan) if (!nodes.ok) return nodes - if (scan.openingSpellableLink) { - const takesDirective = openingLinkTakesDirective(nodes.value, path) - if (!takesDirective.ok) return takesDirective - if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path) - } const { image } = scanned.value - return success(image === undefined ? { nodes: nodes.value } : { image, nodes: nodes.value }) + const { openingSpellableLink } = scan + return success(image === undefined ? { nodes: nodes.value, openingSpellableLink } : { image, nodes: nodes.value, openingSpellableLink }) } -// The reading of a line that faults still holds what the reader took before the fault. +// A read-back never asks the writer for anything: the writer calls it while it spells a line. The reading of a line that faults still holds what the reader took before the fault. export function readLineBack(line: string, container: LineContainer, claims: Claims): { faulted: boolean; reading: InlineReading } { const reading: InlineReading = { codeSpans: [], emphasis: new Map(), links: [] } const scan = freshScan(line, { definitions: noDefinitions, path: [], reading, targets: targetMemo(line) }, { claims, container, inLink: false, offset: 0, spans: noSpans }) @@ -272,7 +271,7 @@ function directiveMarkPieces(scan: Scan, name: string, mark: AdfMark, slot: read return success([...marked.filter((piece) => piece.kind === 'nodes'), ...besideLink(images, link)]) } -// spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `parseInlineContent` asks the emitter. +// spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `parse/` asks the writer once `parseInlineContent` reports the opening. function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[], index: number): Result | undefined { const href = linkHref(nodeAttrs(mark)) if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.shared.path) diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts new file mode 100644 index 0000000..dea6604 --- /dev/null +++ b/src/markdown/parse/inline-content.ts @@ -0,0 +1,17 @@ +import type { ConvertErrorPath, Result } from '../../result.ts' +import type { Flavour } from '../flavour.ts' +import type { InlineContent, LinkDefinitions } from '../inline-content.ts' +import type { LineContainer } from '../line-container.ts' +import { failure, success } from '../../result.ts' +import { openingLinkTakesDirective } from '../emit/spelling-questions.ts' +import { parseInlineContent, spellableLink } from '../inline-content.ts' + +// The only place a reader asks the writer: whether a spellable link opening the paragraph still needs its directive for the line it opens (spec/flavour.md, Marks). +export function readInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result { + const content = parseInlineContent(source, definitions, path, container, flavour) + if (!content.ok || !content.value.openingSpellableLink) return content + const takesDirective = openingLinkTakesDirective(content.value.nodes, path) + if (!takesDirective.ok) return takesDirective + if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path) + return success(content.value) +} diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 48c534a..96aba31 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -5,7 +5,7 @@ import type { ConvertFault } from '../../result.ts' import type { LineContainer } from '../line-container.ts' import type { LinkDefinitions } from '../inline-content.ts' import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts' -import { commonMarkSpelling, openingLinkTakesDirective, spellingMemo, type SpellingMemo } from '../emit/spelling-questions.ts' +import { commonMarkSpelling, spellingMemo, type SpellingMemo } from '../emit/spelling-questions.ts' import { documentAttribute, documentName, documentSpelling } from '../directive/block.ts' import { emptyKeys, nodeAttrs } from '../../adf/document.ts' import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result } from '../../result.ts' @@ -14,8 +14,8 @@ import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { notAString } from '../../not-a-string.ts' import { parseBlocks } from './blocks.ts' -import { parseInlineContent } from '../inline-content.ts' import { readBlockDirectiveNode } from '../directive/node-reading.ts' +import { readInlineContent } from './inline-content.ts' import { spellStringAttribute, unsupportedNodeShape } from '../directive/syntax.ts' import { spellsEmpty } from '../directive/empty-keys.ts' import { withinEngineLimits } from '../../length-bound.ts' @@ -246,14 +246,14 @@ function codeBlockNode(language: string, text: string, reading: Reading, path: C // spec/flavour.md, The CommonMark image: only a `paragraph` block gives a lone image the block it needs. function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result { - const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour, openingLinkTakesDirective) + const content = readInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour) if (!content.ok) return content const image = content.value.image return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image) } export function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result { - const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour, openingLinkTakesDirective) + const content = readInlineContent(text, reading.definitions, path, container, reading.flavour) if (!content.ok) return content return success(withContent(node, content.value.nodes)) } diff --git a/src/markdown/portable/portable-markdown-to-adf.ts b/src/markdown/portable/portable-markdown-to-adf.ts index be1039f..aee2e5c 100644 --- a/src/markdown/portable/portable-markdown-to-adf.ts +++ b/src/markdown/portable/portable-markdown-to-adf.ts @@ -7,10 +7,9 @@ import { containerNode, contentNode, listNode, readBlocks, readDocument, tooDeep import { largestNesting } from '../../nesting.ts' import { leadingMarker, readAlertMarker, readTaskMarker } from './conventions.ts' import { mintTaskIds } from './task-ids.ts' -import { openingLinkTakesDirective } from '../emit/spelling-questions.ts' -import { parseInlineContent } from '../inline-content.ts' import { pathTo, splitTaskList } from './task-list.ts' import { positioned, success } from '../../result.ts' +import { readInlineContent } from '../parse/inline-content.ts' import { withinEngineLimits } from '../../length-bound.ts' type Paragraph = Extract @@ -58,7 +57,7 @@ function quoteReader(foldedType: 'expand' | 'nestedExpand'): FlavourBlockReaders const { folded, panelType } = led.marker const { line, rest } = markerLine(led.text) if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1)) - const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour, openingLinkTakesDirective) + const title = readInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour) if (!title.ok) return title const text = titleText(title.value.nodes) return filledNode(text === '' ? { type: foldedType } : { attrs: { title: text }, type: foldedType }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, blocks: expandBlockReaders }, path, depth + 1)) diff --git a/todo.md b/todo.md index 401254e..121a972 100644 --- a/todo.md +++ b/todo.md @@ -12,7 +12,6 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 153 | 0.4.0 | | | **Return from `parseInlineContent` whether a link opens the paragraph in a spellable form, ask the writer only in `parse/`, and ban `src/markdown/` root files from importing `emit/`, `parse/` and `portable/`, and `emit/` from importing `parse/`.** | 2 | 1 | 2 | 3 | 4 | code health by 3 | 13.1 | | 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 | **Move each CommonMark spelling's description out of `commonMarkSpellings`, beside the emitter's spelling it describes.** | 2 | 1 | 1 | 2 | 4 | code health by 1 | 4.4 | @@ -52,27 +51,6 @@ ## Details -### 153. Return from `parseInlineContent` whether a link opens the paragraph in a spellable form, ask the writer only in `parse/`, and ban `src/markdown/` root files from importing `emit/`, `parse/` and `portable/`, and `emit/` from importing `parse/`. - -Today `parseInlineContent` (`src/markdown/inline-content.ts`) takes `openingLinkTakesDirective`, the -writer's answer, as an argument, and all three callers (`parse/markdown-to-adf.ts`, -`portable/portable-markdown-to-adf.ts`) pass the same function. The writers read lines back through -`readLineBack`, which never asks the writer, so nothing recurses today. A root file importing the -writer would close a module cycle, and a read-back honouring `openingSpellableLink` would recurse -without bound on a paragraph opening with a spellable `!adf:link` (§Nothing recurses unbounded). - -- `parseInlineContent` drops the argument and returns `openingSpellableLink` beside the nodes and - the image. One function in `parse/` runs the scan, asks `emit/spelling-questions.ts` and refuses - with `spellableLink`; both readers call it, so `portable/` drops its `emit/` import. -- `.oxlintrc.json`: an override for `src/markdown/*.ts` bans `emit/`, `parse/`, `portable/` and - `html/`, since a later override replaces an earlier one's rule; `emit/`'s override also bans - `parse/`. `ci.sh` probes both. §The source parts by ADF and format names the root's two bans. -- A comment on `readLineBack` says a read-back never asks the writer, and the false comment at - `inline-content.ts:120` goes. A round-trip test covers a paragraph opening with a spellable - `!adf:link`. -- The reader panel's smaller findings, not in this item: `commonmark/` and root names such as - `adjacent-text.ts` do not say which side they serve. - ### 80. Name the node's path and the attribute key when `not-an-adf-document` refuses a node inside the document. Today `readAdfDocument` and the converters refuse every node, such as one with a `Date` in an