diff --git a/CHANGELOG.md b/CHANGELOG.md index e3d05ba..d4fd482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ### Fixed - Show a table written with a header row and no body rows as the table it is, instead of adding an empty row beneath it that holds no cells and takes no text. +- Keep the outer pipes a table's rows were written with, so a table authored without them stays that way instead of gaining one on both sides of every row on the first save. A table inserted from the editor is still written with both, and so is one whose own form would no longer be read back as the table it is. - Keep the Markdown an image's description was written with, so `![Alt with *emphasis*](leaf.svg)` keeps its emphasis and `![Outer ![inner](inner.svg)](leaf.svg)` keeps the image inside it, instead of flattening the description to its text on open and losing the inner image's destination from the file on the first save. The image is still named by the text its description spells, and a description edited in the raw image Markdown is written as the text typed there. - Read a typed `*` or `_` run the way Markdown reads the same characters in a file, so `***text*` gives two literal asterisks before italic text, `_**text**` a literal underscore before bold text, and `_**text**_` italic bold, instead of leaving every marker as text that saved with backslashes and reopened without the formatting. A run whose closing marker is shorter than its opening one is read once the caret leaves it, because another marker typed there would spell something else. - Pair a `*`, `_`, or `~` typed against bold, italic, or strikethrough with the matching literal marker already on the other side of it, so closing `_**text**` with a `_` gives italic bold and saves `_**text**_`, instead of leaving both markers as text that saved as `\_**text**\_` and reopened without the italic. A marker a file keeps literal by escaping it stays literal. diff --git a/docs/decisions.md b/docs/decisions.md index 6466163..d34b710 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -146,6 +146,7 @@ - Neither autolink form has a literal state, and this is not fixed. `mdast-util-gfm-autolink-literal` contributes an escape for the characters that would otherwise read as a protocol, a `www` lead, or an email marker, but its `fromMarkdown` side runs a `findAndReplace` over already-decoded text, after escapes are resolved, so the escape never changes what a reload produces: `https\://example.com` and `\` both reopen as a link, examined in [issue #241](https://github.com/Azganoth/leafdown/issues/241). Leafdown stops emitting an escape that cannot hold rather than pursue a parser change to give a bare URL a literal state, which is declined for the same reason the canonical form above is not. Inline code is the only durable way to show a URL or an email as text, because its content is never a `text` node the autolink transform visits. - A character reference is decoded by `micromark` before the mdast text node exists, so `©` and `©` are indistinguishable to everything downstream and a file written to stay ASCII does not stay ASCII. Leafdown records the authored form, decided in [issue #262](https://github.com/Azganoth/leafdown/issues/262), and writes it back in text and in link and image destinations alike. The run is recovered by walking each text node's value against the slice of the file it was built from, and carried on a mark whose stored source is verified against the text it covers before it is written, so an edit that invalidates it degrades to the character rather than to a stale reference. References written next to each other keep one mark each, decided in [issue #305](https://github.com/Azganoth/leafdown/issues/305), so breaking one converts only that one and leaves its neighbours preserved. ProseMirror merges neighbouring text nodes carrying an equal mark set, so a repeated reference still arrives as one node holding its characters repeated; only an equal mark merges, which makes that node whole repetitions of the one source it stores, and the verification counts them rather than reading the node as a reference the source does not spell. A preserved reference is inert for escaping: it opens no construct and closes none, and the escape passes read it as the characters it will be written as. That same verified source is what a caret reaching the reference projects, decided in [issue #298](https://github.com/Azganoth/leafdown/issues/298) on the rule [Offer the escape gesture only where the conversion exists](#offer-the-escape-gesture-only-where-the-conversion-exists) states, because breaking a valid reference commits the literal text it spells and the conversion therefore exists. This is the exception the byte-identity target in [issue #251](https://github.com/Azganoth/leafdown/issues/251) would otherwise have had to admit, and it is overridden rather than accepted, unlike the strikethrough run below, because a reference and the character it names are not interchangeable to an author who chose one. - The preset's single thematic break spelling is overridden. Its `hr` node carries no attributes, so `***`, `---`, `_ _ _`, and every other accepted run parse into the same node and are written back as `***`, rewriting every break in a file on its first save. Leafdown records the run on the node, decided in [issue #319](https://github.com/Azganoth/leafdown/issues/319), read from the slice of the file the node was built from, which is the whole of a break because it holds no children. Indentation stands outside that slice and the whitespace closing the line is trimmed off it, so what is kept is the characters and the spacing between them, tabs included. A break the editor creates carries `***`, which is also what a recorded run gives way to where the line it lands on would be read back as something other than a break. `mdast-util-to-markdown` joins a tight list item's children with a single newline, so a run of hyphens written after a paragraph there underlines it into a setext heading; and a run sharing its item's bullet character stands on the bullet's line, where the two read as one longer break with no list around them. The serializer already moves the bullet off the rule character it was configured with, but that character cannot answer for a run the node carries, so the run is what gives way rather than the bullet. +- The preset's outer table pipes are overridden. `mdast-util-gfm-table` calls `markdown-table` with the alignment, the padding, and the cell width it was configured with and never with `delimiterStart` or `delimiterEnd`, and exposes neither as a setting, so a table authored in GFM's pipe-optional form is written back with an outer pipe on both sides of every row. Leafdown records which outer pipes the rows carry, decided in [issue #349](https://github.com/Azganoth/leafdown/issues/349), read from the slice of the file each row was built from, and writes them from a `table` handler of its own. A table the editor creates carries both pipes, which is also what a recorded form gives way to where the rows it now holds would not be read back from the form. A blank cell at either end of a row leaves the written row opening or closing on a pipe of its own, which GFM strips before it splits the row, moving every cell after it one column; and a delimiter cell is as wide as its column, so a first column one character wide is written `-`, which opens a bullet list item where no pipe precedes it. Whether a table carries outer pipes is a property of the table rather than a layout computed across its cells, which is what separates it from the padding the consequence above normalizes: it survives an edit to any cell. The delimiter row is no node of its own, so the form is read off the rows that are, and a table whose rows disagree keeps the pipe rather than taking it off the rows that carry one. - The preset's strikethrough delimiter run is not preserved. Its strikethrough mark carries no marker attribute, unlike emphasis and strong, so a single-tilde run parses and serializes back as a double-tilde run. This is normalized on cost under [Preserve the form a file was written in](#preserve-the-form-a-file-was-written-in) rather than overridden as the autolink form was, because both runs mean the same thing to a GFM reader. Preserving the authored run would require carrying the marker on the mark. - The preset's strikethrough input rule is overridden. Its `(~{1,2})` backtracks to a one-tilde delimiter run when no two-tilde closing run exists yet, and its content group does not exclude the marker, so typing `~~text~~` created a mark over `~text` on the seventh keystroke and left a surplus tilde on each side that saved as an escaped character. Leafdown carries its own rule, decided in [issue #233](https://github.com/Azganoth/leafdown/issues/233), which excludes the marker from the content and anchors the match at the caret so a run stays literal text until the author closes it. This is the only input rule Leafdown owns; every other preset rule either anchors at the caret or excludes its own marker, and none of them can match a run this way. - The replacement rule keeps the preset's leading word, colon, and slash guard, so a tilde run that touches one of those does not become a strikethrough as it is typed. `lead~~text~~` and `1~2~3` parse as strikethrough when a file holds them but stay literal text when typed, which is a real disagreement, examined in [issue #282](https://github.com/Azganoth/leafdown/issues/282) and left as it is. The guard does two jobs: it holds the word boundary, and it stops a one-tilde run from opening inside an unclosed two-tilde one. Removing it fixes the first case and breaks `~~a~b~~`, which types as a struck `b` between literal tildes, because an input rule reads only the text before the caret and cannot know another tilde is coming. Separating the two jobs means matching delimiter runs directly rather than through `markRule`, which reads one content group and cannot express the alternation. Leafdown prefers the conservative failure: literal text the author can see and correct, over a construct silently built around the wrong delimiters. diff --git a/docs/specification.md b/docs/specification.md index 101500e..3289025 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -257,6 +257,7 @@ For editor input and clipboard ownership, see [Architecture](./architecture.md#e - A link or image title keeps the quotation marks or parentheses it was authored with. A parenthesized title whose text holds a parenthesis is written with quotation marks instead, because CommonMark reads such a title between matching parentheses. A reference definition writes its own title on the same rule. - A full, collapsed, or shortcut reference link or image is written back in the form it was authored in, with its definition, rather than as an inline copy of the destination the definition names. Each reference keeps the casing and spacing its label was written with, though references matching one definition still resolve together. - A thematic break keeps the character run it was authored with, including the spaces or tabs written between its characters. Indentation before the run and whitespace after it are not part of it and are not written. A break the editor inserts is written as `***`. A break whose authored run would be read back as something else where it lands is written as `***` instead: a run of hyphens directly under a paragraph in a tight list item underlines it, and a run sharing its list item's bullet character joins that bullet into one longer break. +- A table keeps the outer pipes its rows were authored with, whether both, one, or neither. A table the editor creates is written with both. A table whose rows disagree keeps the pipe the rows that carry one were written with. A table is written with both pipes instead wherever its own form would not be read back: when the first or last cell of any row is blank, or when its first column is one character wide and carries no alignment marker. Cell padding, delimiter row width, and the padding an alignment marker redistributes are normalized rather than kept, because no part of the table owns a width computed across a column. - A blank paragraph between blocks survives save and reopen. - Save output trims trailing blank lines and writes at most one final line ending, controlled by `Insert final newline on save`. Trailing blank paragraphs go with them. diff --git a/package.json b/package.json index 04a942d..0a23c14 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "clsx": "^2.1.1", "decode-named-character-reference": "^1.3.0", "lucide-react": "^1.37.0", + "markdown-table": "3.0.4", "mdast-util-to-markdown": "2.1.2", "micromark-util-decode-numeric-character-reference": "^2.0.2", "micromark-util-html-tag-name": "2.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3932913..52e55f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: lucide-react: specifier: ^1.37.0 version: 1.37.0(react@19.2.8) + markdown-table: + specifier: 3.0.4 + version: 3.0.4 mdast-util-to-markdown: specifier: 2.1.2 version: 2.1.2 diff --git a/src/features/editor/commands/inserting/blocks.test.ts b/src/features/editor/commands/inserting/blocks.test.ts index 31f9a5c..98453a1 100644 --- a/src/features/editor/commands/inserting/blocks.test.ts +++ b/src/features/editor/commands/inserting/blocks.test.ts @@ -157,4 +157,16 @@ describe("editor block insertion commands", () => { expect(table.querySelectorAll("tr")).toHaveLength(2); expect(table.querySelectorAll("th, td")).toHaveLength(4); }); + + // A table read from a file writes the outer pipes it was authored with, so one the editor creates + // needs a form of its own. Both pipes are the form every row reads back as the row it was + // written from. + it("writes an inserted table with the default outer pipes", async () => { + const mounted = await mountEditor("First"); + + setSelectionAtDocumentEnd(mounted.view); + + expect(insertTable(mounted.view)).toBe(true); + expect(mounted.getMarkdown()).toContain("| | |\n| :- | :- |\n| | |\n"); + }); }); diff --git a/src/features/editor/plugins/tableForm.ts b/src/features/editor/plugins/tableForm.ts new file mode 100644 index 0000000..0e6ff5f --- /dev/null +++ b/src/features/editor/plugins/tableForm.ts @@ -0,0 +1,42 @@ +import type { MarkdownNode } from "@milkdown/kit/transformer"; +import { $remark } from "@milkdown/kit/utils"; + +import { + findTableOuterPipes, + TABLE_MARKDOWN_TYPE, + TABLE_OUTER_PIPES_ATTRIBUTE_NAME, +} from "../utils/tableMarkdown"; + +const TRAILING_WHITESPACE_PATTERN = /[\t ]+$/u; + +// A row's slice already has its container prefix and its indentation taken off, so the outer pipes +// are the first and last characters of it once the whitespace closing the line is trimmed. The +// delimiter row is no node of its own, which is why the form is read off the rows that are. +const readRowSource = (row: MarkdownNode, source: string) => { + const start = row.position?.start.offset; + const end = row.position?.end.offset; + + return start === undefined || end === undefined + ? undefined + : source.slice(start, end).replace(TRAILING_WHITESPACE_PATTERN, ""); +}; + +const markAuthoredOuterPipes = (node: MarkdownNode, source: string) => { + for (const child of node.children ?? []) { + if (child.type === TABLE_MARKDOWN_TYPE) { + const rows = (child.children ?? []) + .map((row) => readRowSource(row, source)) + .filter((row) => row !== undefined); + + (child as Record)[TABLE_OUTER_PIPES_ATTRIBUTE_NAME] = + findTableOuterPipes(rows); + } + + markAuthoredOuterPipes(child, source); + } +}; + +export const createLeafdownTableFormPlugin = () => + $remark("leafdownTableForm", () => () => (tree, file) => { + markAuthoredOuterPipes(tree as MarkdownNode, String(file)); + }); diff --git a/src/features/editor/tests/markdownCompatibility.test.ts b/src/features/editor/tests/markdownCompatibility.test.ts index 48c82c3..d132cf3 100644 --- a/src/features/editor/tests/markdownCompatibility.test.ts +++ b/src/features/editor/tests/markdownCompatibility.test.ts @@ -955,6 +955,92 @@ describe("Thematic break form", () => { }); }); +describe("Table outer pipe form", () => { + const BOTH_PIPES = "| Alpha | Bravo |\n| ----- | ----- |\n| Gamma | Delta |"; + const NO_PIPES = "Alpha | Bravo\n----- | -----\nGamma | Delta"; + const LEADING_PIPE = "| Alpha | Bravo\n| ----- | -----\n| Gamma | Delta"; + const TRAILING_PIPE = "Alpha | Bravo |\n----- | ----- |\nGamma | Delta |"; + + const replaceCellText = (mounted: MountedMilkdownEditor, text: string, replacement: string) => { + const position = getEditorTextPosition(mounted, text); + const transaction = mounted.view.state.tr.delete(position, position + text.length); + + mounted.view.dispatch( + replacement ? transaction.insertText(replacement, position) : transaction, + ); + }; + + it.each([ + BOTH_PIPES, + NO_PIPES, + LEADING_PIPE, + TRAILING_PIPE, + // A table with no body rows carries its form on the one row it has. + "Alpha | Bravo\n----- | -----", + // An alignment marker widens the delimiter cell past the lone hyphen that would open a bullet + // list, so a one-character first column keeps the form it was authored with. + "A | Bravo\n:- | -----\nc | Delta", + // The delimiter row only has to answer for a bullet where no pipe precedes it. + "| A | Bravo\n| - | -----\n| c | Delta", + ])("writes the outer pipes in %j as they were authored", async (source) => { + const mounted = await mountEditor(`${source}\n`); + + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }); + + it.each([ + { name: "a blockquote", source: "> Alpha | Bravo\n> ----- | -----\n> Gamma | Delta" }, + { name: "a list item", source: "* Alpha | Bravo\n ----- | -----\n Gamma | Delta" }, + ])("keeps the authored form inside $name", async ({ source }) => { + const mounted = await mountEditor(`${source}\n`); + + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }); + + // One row's pipe answers for the table, so a form is dropped only where every row was authored + // without it. Taking the pipes off the rows that carry them would rewrite more than it keeps. + it("keeps an outer pipe the rows of a table disagree about", async () => { + const mounted = await mountEditor("| Alpha | Bravo |\n| ----- | ----- |\nGamma | Delta\n"); + + expect(mounted.getMarkdown()).toBe(`${BOTH_PIPES}\n`); + }); + + // A blank cell at either end of a row leaves the written row opening or closing on a pipe of its + // own, which GFM strips before it splits the row. No source can author one into a table with no + // outer pipes, and an edit can. + it.each([ + { + cell: "Gamma", + name: "first", + saved: "| Alpha | Bravo |\n| ----- | ----- |\n| | Delta |\n", + }, + { + cell: "Delta", + name: "last", + saved: "| Alpha | Bravo |\n| ----- | ----- |\n| Gamma | |\n", + }, + ])( + "writes a table whose $name cell an edit emptied with its outer pipes", + async ({ cell, saved }) => { + const mounted = await mountEditor(`${NO_PIPES}\n`); + + replaceCellText(mounted, cell, ""); + + expect(mounted.getMarkdown()).toBe(saved); + }, + ); + + // A delimiter cell is as wide as its column, so a first column narrowed to one character is + // written `-`, and that hyphen opens a bullet list where no pipe precedes it. + it("writes a table whose first column an edit narrowed to a hyphen with its outer pipes", async () => { + const mounted = await mountEditor("Alpha | Bravo\n----- | -----\n"); + + replaceCellText(mounted, "Alpha", "A"); + + expect(mounted.getMarkdown()).toBe("| A | Bravo |\n| - | ----- |\n"); + }); +}); + describe("Link and image title form", () => { it.each([ '[Double quote](garden.md "Garden")', diff --git a/src/features/editor/utils/createMilkdownEditor.ts b/src/features/editor/utils/createMilkdownEditor.ts index 0d9fe97..18fb6cd 100644 --- a/src/features/editor/utils/createMilkdownEditor.ts +++ b/src/features/editor/utils/createMilkdownEditor.ts @@ -81,6 +81,7 @@ import { hasTransientSourceProjection, } from "../plugins/sourceProjection"; import { createLeafdownStrikethroughInputRule } from "../plugins/strikethroughInputRule"; +import { createLeafdownTableFormPlugin } from "../plugins/tableForm"; import { createLeafdownTableKeyboardPlugin } from "../plugins/tableKeyboard"; import { createLeafdownTableShapeGuardPlugin, @@ -120,6 +121,7 @@ import { serializeRawHtml, } from "./rawHtmlMarkdown"; import { withImageReferenceForm, withLinkReferenceForm } from "./referenceLinkMarkdown"; +import { serializeTable, withTableOuterPipes } from "./tableMarkdown"; import { serializeThematicBreak, withThematicBreakMarker } from "./thematicBreakMarkdown"; export interface MilkdownMarkdownUpdate { @@ -235,6 +237,7 @@ export const createMilkdownEditor = async ({ .use(createLeafdownThematicBreakPlugin()) .use(createLeafdownBlockStructurePlugin()) .use(createLeafdownMarkNestingPlugin()) + .use(createLeafdownTableFormPlugin()) .use(createLeafdownTableShapePlugin()) .use(commonmark) .use(createLeafdownTableKeyboardPlugin()) @@ -296,6 +299,7 @@ export const createMilkdownEditor = async ({ imageReference: serializeMarkdownImageReference, link: serializeMarkdownLink, root: serializeMarkdownRoot, + table: serializeTable, text: serializeMarkdownText, thematicBreak: serializeThematicBreak, }, @@ -346,7 +350,7 @@ export const createMilkdownEditor = async ({ // A `table_row+` content expression has no legal header-only table, so ProseMirror fills a // cell-less row into every table authored without body rows. ctx.update(tableSchema.key, (getSchema) => (schemaCtx) => ({ - ...getSchema(schemaCtx), + ...withTableOuterPipes(getSchema(schemaCtx)), content: "table_header_row table_row*", })); // `extendSchema` registers a new slice, so an override on `listItemSchema` never reaches the diff --git a/src/features/editor/utils/tableMarkdown.ts b/src/features/editor/utils/tableMarkdown.ts new file mode 100644 index 0000000..abe2c8b --- /dev/null +++ b/src/features/editor/utils/tableMarkdown.ts @@ -0,0 +1,184 @@ +import type { remarkStringifyOptionsCtx } from "@milkdown/kit/core"; +import type { MarkdownNode, NodeSchema } from "@milkdown/kit/transformer"; +import { markdownTable } from "markdown-table"; + +type RemarkStringifyHandlers = NonNullable< + ReturnType["handlers"] +>; + +type StringifyState = Parameters>[2]; + +type StringifyInfo = Parameters>[3]; + +type JoinArguments = Parameters; + +// Milkdown types a stringify handler's node as `any`, so the table is named here from the blocks +// the serializer joins. +type TableNode = Extract; + +export const TABLE_MARKDOWN_TYPE = "table"; +export const TABLE_OUTER_PIPES_ATTRIBUTE_NAME = "outerPipes"; + +const TABLE_ROW_MARKDOWN_TYPE = "tableRow"; + +const OUTER_PIPE = "|"; + +// Which outer pipes a table's rows were authored with. GFM reads a row opening with a pipe, +// closing with one, carrying both, or carrying neither as the same row. +const TABLE_OUTER_PIPES_FORMS = ["both", "leading", "trailing", "none"] as const; + +type TableOuterPipes = (typeof TABLE_OUTER_PIPES_FORMS)[number]; + +// The form a table is written with when it has none of its own: one the editor created, one whose +// authored form cannot be recovered, and one whose own form the rows it now holds would not be +// read back from. Both pipes are the form every row reads back as the row it was written from. +export const DEFAULT_TABLE_OUTER_PIPES: TableOuterPipes = "both"; + +const isTableOuterPipes = (value: unknown): value is TableOuterPipes => + TABLE_OUTER_PIPES_FORMS.includes(value as TableOuterPipes); + +export const readTableOuterPipes = (source: object): TableOuterPipes => { + const form = (source as Record)[TABLE_OUTER_PIPES_ATTRIBUTE_NAME]; + + return isTableOuterPipes(form) ? form : DEFAULT_TABLE_OUTER_PIPES; +}; + +const hasLeadingPipe = (form: TableOuterPipes) => form === "both" || form === "leading"; + +const hasTrailingPipe = (form: TableOuterPipes) => form === "both" || form === "trailing"; + +// A row is written without an outer pipe only where every row was authored without it, so a table +// whose rows disagree keeps the pipe rather than taking it off the rows that carry one. A trailing +// pipe an author escaped into a cell counts as one for the same reason: reading it as a delimiter +// keeps a pipe the file already had. +export const findTableOuterPipes = (rows: readonly string[]): TableOuterPipes => { + if (rows.length === 0) { + return DEFAULT_TABLE_OUTER_PIPES; + } + + const leading = rows.some((row) => row.startsWith(OUTER_PIPE)); + const trailing = rows.some((row) => row.length > OUTER_PIPE.length && row.endsWith(OUTER_PIPE)); + + if (leading) { + return trailing ? "both" : "leading"; + } + + return trailing ? "trailing" : "none"; +}; + +const readCell = (row: readonly string[], index: number) => row[index] ?? ""; + +// A cell holding nothing but the padding around it leaves the written row opening or closing on a +// pipe of its own, and GFM strips that pipe before it splits the row. The cell it stood for is +// gone and every cell after it has moved a column, so the form gives way rather than write a row +// the next open reads differently. +const hasBlankEdgeCell = (matrix: readonly string[][], edge: "leading" | "trailing") => { + const width = Math.max(...matrix.map((row) => row.length)); + + return matrix.some((row) => readCell(row, edge === "leading" ? 0 : width - 1).trim() === ""); +}; + +// `markdown-table` sizes a delimiter cell to its column, so a first column one character wide is +// written `-`. With no pipe ahead of it, that hyphen and the space after it open a bullet list item +// and the lines stop being a table. An alignment marker widens the cell past a lone hyphen, so +// only a column carrying none can reach this. +const opensDelimiterRowWithBullet = (matrix: readonly string[][], align: TableNode["align"]) => + !align?.[0] && Math.max(...matrix.map((row) => readCell(row, 0).length)) <= 1; + +const keepsRowsReadable = ( + form: TableOuterPipes, + matrix: readonly string[][], + align: TableNode["align"], +) => { + if ( + !hasLeadingPipe(form) && + (hasBlankEdgeCell(matrix, "leading") || opensDelimiterRowWithBullet(matrix, align)) + ) { + return false; + } + + return hasTrailingPipe(form) || !hasBlankEdgeCell(matrix, "trailing"); +}; + +// `mdast-util-gfm-table` calls `markdown-table` with the alignment, the padding, and the cell width +// it was configured with and never with the outer delimiters, so the option a preserved form needs +// is only reachable from a handler of Leafdown's own. The rows are built through the `tableCell` +// handler the extension registers, which is what escapes a pipe standing inside a cell. +export const serializeTable: NonNullable = ( + node: TableNode, + _parent, + state, + info: StringifyInfo, +) => { + const exitTable = state.enter(TABLE_MARKDOWN_TYPE); + const matrix = node.children.map((row) => { + const exitRow = state.enter(TABLE_ROW_MARKDOWN_TYPE); + const cells = row.children.map((cell) => state.handle(cell, row, state, info)); + + exitRow(); + + return cells; + }); + + exitTable(); + + const authored = readTableOuterPipes(node); + const form = keepsRowsReadable(authored, matrix, node.align) + ? authored + : DEFAULT_TABLE_OUTER_PIPES; + + return markdownTable(matrix, { + align: node.align, + delimiterStart: hasLeadingPipe(form), + delimiterEnd: hasTrailingPipe(form), + }); +}; + +// The preset's own runners carry the alignment down to the cells and mark the header row, and both +// are replaced rather than wrapped because the form has to reach the node the runner opens. +export const withTableOuterPipes = (schema: NodeSchema): NodeSchema => ({ + ...schema, + attrs: { + ...schema.attrs, + [TABLE_OUTER_PIPES_ATTRIBUTE_NAME]: { + default: DEFAULT_TABLE_OUTER_PIPES, + validate: "string", + }, + }, + parseMarkdown: { + ...schema.parseMarkdown, + runner: (state, node, type) => { + const align = node.align as (string | null)[]; + const rows = (node.children ?? []).map((row, index): MarkdownNode => ({ + ...row, + align, + isHeader: index === 0, + })); + + state.openNode(type, { [TABLE_OUTER_PIPES_ATTRIBUTE_NAME]: readTableOuterPipes(node) }); + state.next(rows); + state.closeNode(); + }, + }, + toMarkdown: { + ...schema.toMarkdown, + runner: (state, node) => { + const headerRow = node.content.firstChild?.content; + + if (!headerRow) { + return; + } + + const align: (string | null)[] = []; + + headerRow.forEach((cell) => align.push(cell.attrs.alignment as string | null)); + + state.openNode(TABLE_MARKDOWN_TYPE, undefined, { + align, + [TABLE_OUTER_PIPES_ATTRIBUTE_NAME]: readTableOuterPipes(node.attrs), + }); + state.next(node.content); + state.closeNode(); + }, + }, +});