3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Read a GFM pipe table's short rows, rows without a pipe and one column in portableMarkdownToAdf

This commit is contained in:
2026-11-09 11:27:40 +01:00
committed by Lillem4n
parent 429ec8ccb8
commit db2c395d05
27 changed files with 110 additions and 38 deletions
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
- `portableMarkdownToAdf` reads GFM's pipe table further: a body row holding fewer cells than the
header is padded with empty cells, a line holding no `|` after the table is a row of one cell,
and `a |` over `--- |` is a one-column table. A row holding more cells stays an error result.
`MIGRATION.md` shows the change.
- `adfToPortableMarkdown` keeps a link around the note standing in place of a node it marks, such
as a stored image: `[_(image not included)_](https://example.com/a)`.
- **Breaking:** `readAdfDocument` replaces `isAdfDocument`: it returns a `Result<AdfDocument>` whose
+8
View File
@@ -22,6 +22,14 @@ class that keeps its fields behind getters (Vue 2, MobX) is refused too: hand in
`JSON.parse(JSON.stringify(document))`. A document holding more than 10,000,000 JSON values is
`unsupported-node-shape`.
`portableMarkdownToAdf` reads more of GFM's pipe table. A body row holding fewer cells than the
header is padded with empty cells, where `0.3.1` returned `malformed-pipe-table`. A line holding no
`|` after a table is a row of one cell, where it ended the table and read as a paragraph. `a |` over
`--- |` is a one-column table, where it read as a paragraph. A markdown string that relied on the
old reading takes a blank line between the table and the paragraph. `losslessMarkdownToAdf` reads
the first two as before, and now returns `malformed-pipe-table` for `a |` over `--- |` too, as it
does for any table without leading pipes.
## From `0.2.0` to `0.3.0`
Every reader skips code text when it spells a mark other than a link, and `losslessMarkdownToAdf`
+9 -8
View File
@@ -602,7 +602,7 @@ Which reader raises which code: [The calls](#the-calls). Codes every direction r
| 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 an opaque carry | write the spelling the message names, or keep it literal: escape the prefix — `\!adf:`, block and inline alike — or, for a code fence whose info string opens `adf:`, drop the info string and wrap the fence in `!adf:codeBlock {language="adf:…"}` |
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing `---` delimiter row, or a delimiter or body row whose cell count differs from the header's; in `losslessMarkdownToAdf` also an alignment colon, and a header or delimiter row not opening with a pipe | add a `---` row under the header and give every row the header's cell count; for `losslessMarkdownToAdf`, also open every row with a pipe and drop the colons. To keep the lines literal text, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way — or, for a table without leading pipes, write one `\|` of its delimiter row as `\\|` |
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing `---` delimiter row, a delimiter row whose cell count differs from the header's, or a body row holding more cells than the header; in `losslessMarkdownToAdf` also a body row holding fewer cells, an alignment colon, and a header or delimiter row not opening with a pipe | add a `---` row under the header and give no row more cells than the header, and for `losslessMarkdownToAdf` the header's count exactly; for `losslessMarkdownToAdf`, also open every row with a pipe and drop the colons. To keep the lines literal text, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way — or, for a table without leading pipes, write one `\|` of its delimiter row as `\\|` |
| `not-a-string` | the value handed in is not a string, such as `null`, a number or a `String` object | pass the input as a string, `String(value)` for a `String` object; the message names the value's type |
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/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 raw HTML, a comment and a processing instruction among them; the HTML handed to `htmlToAdf` holds HTML `adfToHtml` does not write, in a browser's spelling or its own, such as an edit to what a carry element shows, unless a code under [Either direction](#either-direction) names the cause | remove the construct, or write what it holds as markdown; for `htmlToAdf`, write the HTML as [`spec/html.md`](https://github.com/larvit/adf-codec/blob/main/spec/html.md) spells it |
@@ -727,13 +727,14 @@ Serves [Goals](#goals) 1, 4 and 5.
levels, a document meeting an `unsupported-node-shape` cause listed for it, for a writer or for
every call, and a conversion `engine-limit` stops. Show the refusal and keep the document read-only; saving
markdown you could not produce is the loss the round-trip exists to stop.
- Both readers narrow GFM's pipe table. A body row whose cell count differs from the header's is
an error result, where GFM pads or cuts it. A line holding no `|` ends the table, where GFM reads
it as a row.
- `losslessMarkdownToAdf` also needs a leading `|`: a header or delimiter row without one is an
error result, and a later line without one ends the table. It refuses an alignment colon in the
delimiter row too, since ADF holds no column alignment. `portableMarkdownToAdf` accepts both and
drops the alignment. The trailing pipe is canonical output, optional in input.
- Both readers narrow GFM's pipe table. A body row holding more cells than the header is an error
result, where GFM cuts the extra cells.
- `losslessMarkdownToAdf` narrows it further. A body row holding fewer cells is an error result,
where GFM pads it, and a line holding no `|` ends the table, where GFM reads it as a row. It
needs a leading `|`: a header or delimiter row without one is an error result. It refuses an
alignment colon in the delimiter row too, since ADF holds no column alignment.
`portableMarkdownToAdf` pads, reads the line as a row, accepts both and drops the alignment. The
trailing pipe is canonical output, optional in input.
- Past that and `~~`, `losslessMarkdownToAdf` reads no GFM: an autolink literal and a `- [ ]`
marker stay text, and a checklist is the `taskList` directive — `portableMarkdownToAdf` turns the
marker into a `taskList`.
+8 -4
View File
@@ -388,10 +388,14 @@ and 7. Valid while a refusal is a result and the caller can fix the markdown it
Where GFM's reading of a shape drops content, `portableMarkdownToAdf` refuses the shape. A pipe
table body row holding more cells than its header is `malformed-pipe-table`, where GFM cuts the
extra cells. Cutting the cells breaks Goal 7. Building a table GFM does not, such as a wider one, breaks
Goal 4. A refusal builds no table, but it still breaks Goal 4: a bot gets an error for a row GitHub
renders. Goal 7 is a Maintain goal, holding whatever it costs, so it wins over Goal 4, an Achieve
goal.
extra cells. Cutting the cells breaks Goal 7. Building a table GFM does not, such as a wider one,
breaks Goal 4. A refusal builds no table, but it still breaks Goal 4: a bot gets an error for a row
GitHub renders. Goal 7 is a Maintain goal, holding whatever it costs, so it wins over Goal 4, an
Achieve goal.
A shape whose GFM reading drops nothing is read as GFM reads it: a short body row is padded with
empty cells, a line holding no `|` after the table is a row of one cell, and `a |` over `--- |` is
a one-column table.
## The HTML dialect
+17
View File
@@ -980,3 +980,20 @@ Read most (opens and searches):
- `ci.sh` 1
=======
>>>>>>> origin/main
## Reading cost, chunk a624aacf239f, finished 2026-11-09T10:24:02Z
167585 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `README.md` 6
- `docs/decisions.md` 6
- `spec/flavour.md` 4
- `docs/kovetskiy-mark.md` 3
- `CHANGELOG.md` 2
- `ci.sh` 2
- `corpus/README.md` 1
- `corpus/round-trip/block-nodes/expand.md` 1
- `corpus/round-trip/block-nodes/panel.md` 1
+8 -6
View File
@@ -365,13 +365,15 @@ indentation.
Cells split on unescaped `|` before inline parsing — `\|` stays in the cell text, and the
inline layer's ordinary CommonMark escaping yields the pipe; each cell is the inline content of
one paragraph, trimmed; canonical form pads cells with single spaces and ends rows with `|`
(optional in input). Named errors: a delimiter or body row whose cell count differs from the
header's, and, in `losslessMarkdownToAdf`, an alignment colon in the delimiter row — ADF holds no
column alignment. In a pipe cell a hard break is `!adf:hardBreak{}` and a literal `|` is `\|`; a
(optional in input). Named errors: a delimiter row whose cell count differs from the header's,
a body row holding more cells than the header, and, in `losslessMarkdownToAdf`, a body row
holding fewer cells and an alignment colon in the delimiter row — ADF holds no column alignment. In a pipe cell a hard break is `!adf:hardBreak{}` and a literal `|` is `\|`; a
`|` inside a quoted attribute value is already `\u007c`, so the split never reaches it.
`portableMarkdownToAdf` reads GFM's table: any row may drop its leading `|`, a body row continues
the table while it holds a `|` and opens no other block, and an alignment colon is read and its
alignment dropped.
`portableMarkdownToAdf` reads GFM's table: any row may drop its leading `|`, a header over a
delimiter row of one cell reads as a one-column table (`a |` over `--- |`), a body row continues
the table until a blank line or a line opening another block, a body row holding fewer cells is
padded with empty cells, a line holding no `|` is a body row of one cell, and an alignment colon is
read and its alignment dropped.
The directive form nests cells as containers of block content inside `tableRow` containers:
+1
View File
@@ -0,0 +1 @@
import '../html/emit/adf-to-html.ts';
+1
View File
@@ -0,0 +1 @@
import '../markdown/flavour.ts';
+1
View File
@@ -0,0 +1 @@
import '../markdown/flavour.ts';
@@ -0,0 +1 @@
import '../../markdown/flavour.ts';
+1
View File
@@ -0,0 +1 @@
import '../emit/adf-to-html.ts';
+1
View File
@@ -0,0 +1 @@
import '../html/emit/adf-to-html.ts';
@@ -0,0 +1 @@
import '../emit/image.ts';
@@ -0,0 +1 @@
import '../../html/emit/adf-to-html.ts';
+1
View File
@@ -0,0 +1 @@
import '../parse/blocks.ts';
+1
View File
@@ -0,0 +1 @@
import '../../html/emit/adf-to-html.ts';
+1
View File
@@ -0,0 +1 @@
import '../portable/conventions.ts';
+2
View File
@@ -34,6 +34,8 @@ test('escapes the delimiter row a hard break leaves opening a pipe table with no
assert.equal(broken('--- | ---'), 'a | b\\\n\\--- | ---\n')
assert.equal(broken(':--- | ---:'), 'a | b\\\n\\:--- | ---:\n')
assert.equal(broken('c | d'), 'a | b\\\nc | d\n')
const oneColumn = markdown(adfToLosslessMarkdown(document(paragraph({ text: 'a |', type: 'text' }, { type: 'hardBreak' }, { text: '--- |', type: 'text' }))))
assert.equal(oneColumn, 'a |\\\n\\--- |\n')
})
test('escapes only text that would otherwise open a construct', () => {
+8 -5
View File
@@ -18,6 +18,7 @@ import {
setextHeadingLevel,
thematicBreakTail,
type ThematicBreakTail,
trimSpace,
} from '../commonmark/grammar.ts'
import { barePipeCells, isDelimiterRow, isPipeAlignment, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
import { blockDirectiveForm } from '../directive/block.ts'
@@ -532,11 +533,11 @@ function openGfmTable(walk: Walk, opener: string): boolean {
return true
}
// GFM, Tables: a row without its leading `|` continues the table while it holds a `|` and opens no other block.
// GFM, Tables: a row without its leading `|` continues the table until a blank line or a line opening another block; a line holding no `|` is one cell.
function pipeTableRow(claims: Claims, line: string): string[] | undefined {
const cells = pipeCells(line)
if (cells !== undefined || claims.pipeTables !== 'gfm' || opensBlock(claims, line)) return cells
return barePipeCells(line)
if (cells !== undefined || claims.pipeTables !== 'gfm' || isBlankLine(line) || opensBlock(claims, line)) return cells
return barePipeCells(line) ?? [trimSpace(line)]
}
function pipeTableBlock(rows: readonly [string[], ...string[][]], position: SourcePosition, claims: Claims): Block {
@@ -547,9 +548,11 @@ function pipeTableBlock(rows: readonly [string[], ...string[][]], position: Sour
if (delimiter === undefined || !isDelimiterRow(delimiter)) {
return faultedBlock('a pipe table underlines its header with a row of `-` runs: this one has none; \\| at the start of every row keeps them literal text', position)
}
const ragged = [delimiter, ...body].find((row) => row.length !== header.length)
const padsShortRows = claims.pipeTables === 'gfm'
const ragged = [delimiter, ...body].find((row) => (padsShortRows && row !== delimiter ? row.length > header.length : row.length !== header.length))
if (ragged !== undefined) return faultedBlock(`a pipe table row holds ${cellCount(ragged.length)} where its header holds ${cellCount(header.length)}`, position)
return { kind: 'table', position, rows: [header, ...body] }
const padded = body.map((row) => [...row, ...Array.from({ length: header.length - row.length }, () => '')])
return { kind: 'table', position, rows: [header, ...padded] }
}
// spec/flavour.md, Tables: GFM's table without the leading pipes, which no line of it claims.
@@ -0,0 +1 @@
import '../emit/spelling-questions.ts';
@@ -0,0 +1 @@
import '../../html/emit/adf-to-html.ts';
@@ -0,0 +1 @@
import '../portable/conventions.ts';
+1
View File
@@ -0,0 +1 @@
import '../emit/adf-to-markdown.ts';
@@ -259,6 +259,9 @@ test('names the pipe table whose rows open with no pipe', () => {
assert.equal(content(losslessMarkdownToAdf('Intro.\na | b\n--- | ---\n')), bare)
assert.equal(content(losslessMarkdownToAdf('a | b\n--- | ---\n===\n')), bare)
assert.equal(content(losslessMarkdownToAdf('a | b\n:--- | ---:\n')), bare)
assert.equal(content(losslessMarkdownToAdf('a |\n--- |\n')), bare)
assert.deepEqual(content(losslessMarkdownToAdf('| a | b |\n| --- | --- |\nc\n')), [table(row(cell('tableHeader', text('a')), cell('tableHeader', text('b')))), paragraph('c')])
assert.equal(content(losslessMarkdownToAdf('| a | b |\n| --- | --- |\n| c |\n')), 'malformed-pipe-table: a pipe table row holds 1 cell where its header holds 2 cells')
assert.deepEqual(content(losslessMarkdownToAdf('a | b\nc | d\n')), [paragraph('a | b c | d')])
assert.deepEqual(content(losslessMarkdownToAdf('a | b\n--- | --- | ---\n')), [paragraph('a | b --- | --- | ---')])
assert.deepEqual(content(losslessMarkdownToAdf('a \\| b\n--- | ---\n')), [paragraph('a | b --- | ---')])
+7 -3
View File
@@ -4,10 +4,10 @@ import { backslashEscape, claimsPipeLine, trimSpace } from './commonmark/grammar
const alignmentCell = /^:-+:?$|^-+:$/
const delimiterCell = /^-+$/
// spec/flavour.md, Tables: the cells of a row no leading `|` claimed — GFM's form without the outer pipes.
// spec/flavour.md, Tables: the cells of a row no leading `|` claimed — GFM's form without the outer pipes, one `|` making a one-column row.
export function barePipeCells(line: string): string[] | undefined {
const cells = splitPipeCells(line, 0)
return cells.length > 1 ? cells : undefined
return cells.length > 1 ? withoutClosingCell(cells) : undefined
}
export function isBareDelimiterRow(line: string): boolean {
@@ -33,7 +33,7 @@ export function malformedPipeTable(message: string): ConvertFault {
// spec/flavour.md, Tables: the cells of a claimed row, the closing `|` the spelling writes optional here.
export function pipeCells(line: string): string[] | undefined {
return claimsPipeLine(line) ? splitPipeCells(line, 1) : undefined
return claimsPipeLine(line) ? withoutClosingCell(splitPipeCells(line, 1)) : undefined
}
export function spellPipeDelimiter(columns: number): string {
@@ -60,6 +60,10 @@ function splitPipeCells(line: string, start: number): string[] {
index += 1
}
cells.push(trimSpace(line.slice(cellStart)))
return cells
}
function withoutClosingCell(cells: string[]): string[] {
if (cells.length > 1 && cells.at(-1) === '') cells.pop()
return cells
}
@@ -378,11 +378,9 @@ test('reads GFM\'s pipe table without its leading pipes or with alignment colons
assert.deepEqual(read('a | b\n| --- | --- |\nc | d\n'), [table])
assert.deepEqual(read('Intro.\na | b\n--- | ---\nc | d\n\nAfter.\n'), [said('Intro.'), table, said('After.')])
assert.deepEqual(read('> a | b\n> --- | ---\n> c | d\n'), [bare('blockquote', table)])
assert.deepEqual(read('a | b\n--- | ---\nc | d\nAfter.\n'), [table, said('After.')])
assert.deepEqual(read('a | b\n--- | ---\nc | d\n# h | i\n'), [table, { attrs: { level: 1 }, content: [text('h | i')], type: 'heading' }])
assert.deepEqual(read('a | b\n--- | --- | ---\n'), [said('a | b --- | --- | ---')])
assert.deepEqual(read('- a | b\n--- | ---\n'), [bare('bulletList', bare('listItem', said('a | b --- | ---')))])
assert.deepEqual(read('a | b\n--- | ---\nc\n'), [bare('table', table.content?.[0] ?? said('')), said('c')])
assert.deepEqual(read('a | b\nc | d\n'), [said('a | b c | d')])
assert.deepEqual(read('| a | b |\n| --- | --- |\n | c | d |\n'), [bare('table', table.content?.[0] ?? said('')), bare('codeBlock', text('| c | d |'))])
assert.deepEqual(read('a | b\n--- | ---\n c | d\n'), [bare('table', table.content?.[0] ?? said('')), bare('codeBlock', text('c | d'))])
@@ -391,6 +389,27 @@ test('reads GFM\'s pipe table without its leading pipes or with alignment colons
assert.deepEqual(ragged.ok ? undefined : [ragged.error.code, ragged.error.position], ['malformed-pipe-table', { line: 2, offset: 7 }])
})
test('reads GFM\'s short row padded with empty cells, a line holding no pipe as a row, and a one-column table', () => {
const cell = (type: string, value: string): AdfNode => bare(type, value === '' ? { type: 'paragraph' } : said(value))
const header = bare('tableRow', cell('tableHeader', 'a'), cell('tableHeader', 'b'))
const short = bare('table', header, bare('tableRow', cell('tableCell', 'c'), cell('tableCell', '')))
assert.deepEqual(read('a | b\n--- | ---\nc\n'), [short])
assert.deepEqual(read('| a | b |\n| --- | --- |\n| c |\n'), [short])
assert.deepEqual(read('a | b\n--- | ---\nc | d\nAfter.\n'), [
bare('table', header, bare('tableRow', cell('tableCell', 'c'), cell('tableCell', 'd')), bare('tableRow', cell('tableCell', 'After.'), cell('tableCell', ''))),
])
assert.deepEqual(read('a | b\n--- | ---\nc\n\nAfter.\n'), [short, said('After.')])
assert.deepEqual(read('a | b\n--- | ---\nc\n# h\n'), [short, { attrs: { level: 1 }, content: [text('h')], type: 'heading' }])
const column = (rows: readonly string[]): AdfNode =>
bare('table', ...rows.map((value, index) => bare('tableRow', cell(index === 0 ? 'tableHeader' : 'tableCell', value))))
assert.deepEqual(read('a |\n--- |\nb |\n'), [column(['a', 'b'])])
assert.deepEqual(read('a |\n:--- |\nb\n'), [column(['a', 'b'])])
assert.deepEqual(read('| a |\n| --- |\nb\n'), [column(['a', 'b'])])
assert.deepEqual(read('a\n--- |\n'), [said('a --- |')])
const long = portableMarkdownToAdf('a | b\n--- | ---\nc | d | e\n')
assert.deepEqual(long.ok ? undefined : long.error.code, 'malformed-pipe-table')
})
test('reads what CommonMark reads where no row reads, a directive and a carry fence among it, an image sharing its paragraph with a marker among it', () => {
assert.deepEqual(read('!adf:panel\n'), [said('!adf:panel')])
assert.deepEqual(read('a !adf:carry{json="{}"} \\!adf:x\n'), [said('a !adf:carry{json="{}"} !adf:x')])
-10
View File
@@ -13,7 +13,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 |
| 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 | **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 |
@@ -62,15 +61,6 @@ a paragraph opening with a spellable `!adf:link` (§Nothing recurses unbounded).
`src/markdown/**` bans only `html/`; banning `emit/`, `parse/` and `portable/` for the root's own
files, with its `ci.sh` probe, extends the decision.
### 112. 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`.
GFM pads a short row with empty cells, reads a line holding no `|` after a table as a row, and
reads `a |` over `--- |` as a one-column table. `portableMarkdownToAdf` refuses a short row as
`malformed-pipe-table`, ends the table at a line holding no `|`, and reads the one-column shape as a
paragraph. A long row stays refused (`docs/decisions.md` §The portable reader refuses what GFM's
reading would drop), so the README's pipe-table guarantee keeps the long row for both readers, and
the short row and the line holding no `|` for `losslessMarkdownToAdf` alone. The one-column shape needs `isBareDelimiterRow` in the writer's escaping to cover it too.
### 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