From 38a8794d801584154462bb4f9f975faf482fd23c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Thu, 3 Sep 2026 13:47:52 -0300 Subject: [PATCH 1/3] Write an indented code block back as indented code The parse gives both code forms one node carrying a value and an info string, and `mdast-util-to-markdown` picks one form for the whole document, so every block was written fenced. The two forms agree on everything the node carries, so the form survives only in the slice of the file the node was built from. The head of that slice names it. An indented block's position opens on the line its indentation is written on, while a fence's opens at the fence itself, past whatever indentation the file gave it, so indented code can never stand on a fence run: its own four spaces stand there first. Inside a container the position opens past the container's prefix, which leaves the same reading. The form reaches the serializer through the `fences` option rather than being written directly, because the handler's indented branch also holds the conditions CommonMark puts on that form. A block carrying an info string, opening or closing on a blank line, or holding nothing but whitespace cannot be written indented, and giving way to a fence there is what keeps an edit inside the block from writing a file that reads back as something else. The rewrite did not stop at the block either: indented content holding a fence of its own forced a longer outer run, rewriting the lines around a construct the author never wrote. --- src/features/editor/plugins/codeForm.ts | 31 +++++ .../tests/markdownCompatibility.test.ts | 79 +++++++++++-- .../editor/utils/blockSeparatorMarkdown.ts | 33 +----- src/features/editor/utils/codeMarkdown.ts | 111 ++++++++++++++++++ .../editor/utils/createMilkdownEditor.ts | 15 ++- 5 files changed, 219 insertions(+), 50 deletions(-) create mode 100644 src/features/editor/plugins/codeForm.ts create mode 100644 src/features/editor/utils/codeMarkdown.ts diff --git a/src/features/editor/plugins/codeForm.ts b/src/features/editor/plugins/codeForm.ts new file mode 100644 index 0000000..619e241 --- /dev/null +++ b/src/features/editor/plugins/codeForm.ts @@ -0,0 +1,31 @@ +import type { MarkdownNode } from "@milkdown/kit/transformer"; +import { $remark } from "@milkdown/kit/utils"; + +import { + CODE_FENCED_ATTRIBUTE_NAME, + CODE_MARKDOWN_TYPE, + findCodeFenced, +} from "../utils/codeMarkdown"; + +// The parse records the value and the info string, and the two forms of code block agree on both, +// so the form survives only in the slice of the file the node was built from. A node the parser +// gave no position keeps the default. +const markAuthoredForm = (node: MarkdownNode, source: string) => { + for (const child of node.children ?? []) { + const start = child.position?.start.offset; + const end = child.position?.end.offset; + + if (child.type === CODE_MARKDOWN_TYPE && start !== undefined && end !== undefined) { + (child as Record)[CODE_FENCED_ATTRIBUTE_NAME] = findCodeFenced( + source.slice(start, end), + ); + } + + markAuthoredForm(child, source); + } +}; + +export const createLeafdownCodeFormPlugin = () => + $remark("leafdownCodeForm", () => () => (tree, file) => { + markAuthoredForm(tree as MarkdownNode, String(file)); + }); diff --git a/src/features/editor/tests/markdownCompatibility.test.ts b/src/features/editor/tests/markdownCompatibility.test.ts index f18a60a..ea0fc11 100644 --- a/src/features/editor/tests/markdownCompatibility.test.ts +++ b/src/features/editor/tests/markdownCompatibility.test.ts @@ -314,6 +314,10 @@ describe("Markdown compatibility", () => { it.each([ "* ```\n code\n ```", "1. ```\n code\n ```", + // Padding wide enough to hold four spaces past the marker's own one opens indented code, so + // the item's content boundary and the block's indentation are read off the same run. + "* code", + "1. code", "* | A | B |\n | - | - |\n | 1 | 2 |", "1. | A | B |\n | - | - |\n | 1 | 2 |", "* > quoted", @@ -330,18 +334,6 @@ describe("Markdown compatibility", () => { expect(mounted.getMarkdown()).toBe(`${source}\n`); }); - it.each([ - { expected: "* ```\n code\n ```\n", source: "* code" }, - { expected: "1. ```\n code\n ```\n", source: "1. code" }, - ])( - "keeps an indented-code first child inside its list item in $source", - async ({ expected, source }) => { - const mounted = await mountEditor(source); - - expect(mounted.getMarkdown()).toBe(expected); - }, - ); - it.each([ "* A\n* B", "* A\n\n* B", @@ -1209,6 +1201,69 @@ describe("Thematic break form", () => { }); }); +describe("Code block form", () => { + it.each([ + " four spaces open the block", + // Indentation past the four that open the block is content, and stays in it. + " four spaces open the block\n two further spaces stay in the content", + // A blank line inside an indented block does not end it, so the block spans it. + " first\n\n second", + // A fence written inside indented code is content rather than a fence, which is what fencing + // the block would have to spend a longer run to hold. + " ```\n four leading spaces form indented code instead", + ])("writes the indented block in %j as it was authored", async (source) => { + const mounted = await mountEditor(`${source}\n`); + + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }); + + it.each([ + { name: "a list item", source: "- five spaces changes the content indentation boundary" }, + { name: "a blockquote", source: "> quoted indented code" }, + { name: "a list item holding a blockquote", source: "- > deeply indented code" }, + ])("keeps an indented block inside $name", async ({ source }) => { + const mounted = await mountEditor(`${source}\n`); + + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }); + + // An indented block cannot carry an info string, so the two forms are not interchangeable and a + // block holding one is fenced whatever the file wrote. + it.each([ + "```typescript\nconst leaf = true;\n```", + "```\nplain fence\n```", + "> ```\n> quoted fence\n> ```", + ])("writes the fenced block in %j as it was authored", async (source) => { + const mounted = await mountEditor(`${source}\n`); + + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }); + + // A block made in the editor carries no authored form and writes the default. + it.each([ + { commandId: "insert.codeBlock", saved: "Paragraph\n\n```\n```" }, + { commandId: "format.codeBlock", saved: "```\nParagraph\n```" }, + ] as const)("writes a block made by $commandId as a fence", async ({ commandId, saved }) => { + const mounted = await mountEditor("Paragraph\n"); + + await runEditorCommand(mounted.editor, commandId); + + expect(mounted.getMarkdown()).toBe(`${saved}\n`); + }); + + // CommonMark strips the blank lines around indented code, so a block whose content grows one + // cannot be written in that form and gives way to a fence. The recorded form is what the block + // returns to once the edit that reached this is undone. + it("writes an indented block as a fence once its content opens on a blank line", async () => { + const mounted = await mountEditor(" indented\n"); + const { view } = mounted; + + view.dispatch(view.state.tr.insertText("\n", 1)); + + expect(mounted.getMarkdown()).toBe("```\n\nindented\n```\n"); + }); +}); + describe("Table outer pipe form", () => { const BOTH_PIPES = "| Alpha | Bravo |\n| ----- | ----- |\n| Gamma | Delta |"; const NO_PIPES = "Alpha | Bravo\n----- | -----\nGamma | Delta"; diff --git a/src/features/editor/utils/blockSeparatorMarkdown.ts b/src/features/editor/utils/blockSeparatorMarkdown.ts index 860139b..04669ac 100644 --- a/src/features/editor/utils/blockSeparatorMarkdown.ts +++ b/src/features/editor/utils/blockSeparatorMarkdown.ts @@ -273,7 +273,7 @@ const blockAdjacentAttrs = { // The preset's own runners open the mdast node themselves and carry only the fields they know, so // each is replaced rather than wrapped: the authored separator has to reach the node the runner -// opens. Only the four blocks Leafdown holds no other form for are replaced here; the rest carry +// opens. Only the three blocks Leafdown holds no other form for are replaced here; the rest carry // the separator alongside the form their own module already writes. export const withParagraphSeparator = (schema: NodeSchema): NodeSchema => ({ ...schema, @@ -335,37 +335,6 @@ export const withBlockquoteSeparator = (schema: NodeSchema): NodeSchema => ({ }, }); -export const withCodeBlockSeparator = (schema: NodeSchema): NodeSchema => ({ - ...schema, - attrs: { ...schema.attrs, ...blockAdjacentAttrs }, - parseMarkdown: { - ...schema.parseMarkdown, - runner: (state, node, type) => { - const value = node.value as string | undefined; - - state.openNode(type, { - language: node.lang ?? "", - [BLOCK_ADJACENT_ATTRIBUTE_NAME]: readBlockAdjacent(node), - }); - - if (value) { - state.addText(value); - } - - state.closeNode(); - }, - }, - toMarkdown: { - ...schema.toMarkdown, - runner: (state, node) => { - state.addNode(CODE_MARKDOWN_TYPE, undefined, node.content.firstChild?.text ?? "", { - lang: node.attrs.language, - [BLOCK_ADJACENT_ATTRIBUTE_NAME]: readBlockAdjacent(node.attrs), - }); - }, - }, -}); - export const withFootnoteDefinitionSeparator = (schema: NodeSchema): NodeSchema => ({ ...schema, attrs: { ...schema.attrs, ...blockAdjacentAttrs }, diff --git a/src/features/editor/utils/codeMarkdown.ts b/src/features/editor/utils/codeMarkdown.ts new file mode 100644 index 0000000..06094b8 --- /dev/null +++ b/src/features/editor/utils/codeMarkdown.ts @@ -0,0 +1,111 @@ +import type { remarkStringifyOptionsCtx } from "@milkdown/kit/core"; +import type { NodeSchema } from "@milkdown/kit/transformer"; +import { defaultHandlers } from "mdast-util-to-markdown"; + +import { + BLOCK_ADJACENT_ATTRIBUTE_NAME, + DEFAULT_BLOCK_ADJACENT, + readBlockAdjacent, +} from "./blockSeparatorMarkdown"; + +type RemarkStringifyHandlers = NonNullable< + ReturnType["handlers"] +>; + +type StringifyState = Parameters>[2]; + +type JoinArguments = Parameters; + +// Milkdown types a stringify handler's node as `any`, so the block is named here from the blocks +// the serializer joins. +type CodeNode = Extract; + +export const CODE_MARKDOWN_TYPE = "code"; +export const CODE_FENCED_ATTRIBUTE_NAME = "fenced"; + +// The form a block is written in when it has none of its own: one the editor created, and one +// whose authored form cannot be recovered. A fence is the form that carries every block, because +// it is the only one an info string can be written on. +export const DEFAULT_CODE_FENCED = true; + +// A fence opens on three or more of one character. Indented code opens on the four spaces that +// make it, so a slice standing on a run of either character was written as a fence. +const CODE_FENCE_PATTERN = /^(?:`{3,}|~{3,})/u; + +export const readCodeFenced = (source: object): boolean => + (source as Record)[CODE_FENCED_ATTRIBUTE_NAME] !== false; + +// An indented block's slice opens at the line its indentation is written on, and a fence's opens +// at the fence itself, past whatever indentation the file gave it. Neither the value nor the info +// string says which form held the block, so the head of that slice is what names it: indented code +// can never stand on a fence run, because the four spaces that open it stand there first. +export const findCodeFenced = (raw: string): boolean => CODE_FENCE_PATTERN.test(raw); + +// `mdast-util-to-markdown` chooses between the two code forms from one option for the whole +// document, and its indented branch also holds the conditions CommonMark puts on that form: a +// block carrying an info string, opening or closing on a blank line, or holding nothing but +// whitespace cannot be written indented, and is fenced whatever the file wrote. The choice is +// reachable only through that option, so it carries the authored form for the length of the block. +export const serializeCode: NonNullable = ( + node: CodeNode, + parent, + state, + info, +) => { + const { fences } = state.options; + + state.options.fences = readCodeFenced(node); + + try { + return defaultHandlers.code(node, parent, state, info); + } finally { + state.options.fences = fences; + } +}; + +// The preset's own runner opens the mdast node itself and carries only the info string, so it is +// replaced rather than wrapped: the authored form has to reach the node the runner opens. The +// separator every block carries travels with it, the way each block Leafdown holds another form for +// carries it in that form's own module. +export const withCodeForm = (schema: NodeSchema): NodeSchema => ({ + ...schema, + attrs: { + ...schema.attrs, + [CODE_FENCED_ATTRIBUTE_NAME]: { + default: DEFAULT_CODE_FENCED, + validate: "boolean", + }, + [BLOCK_ADJACENT_ATTRIBUTE_NAME]: { + default: DEFAULT_BLOCK_ADJACENT, + validate: "boolean", + }, + }, + parseMarkdown: { + ...schema.parseMarkdown, + runner: (state, node, type) => { + const value = node.value as string | undefined; + + state.openNode(type, { + language: node.lang ?? "", + [CODE_FENCED_ATTRIBUTE_NAME]: readCodeFenced(node), + [BLOCK_ADJACENT_ATTRIBUTE_NAME]: readBlockAdjacent(node), + }); + + if (value) { + state.addText(value); + } + + state.closeNode(); + }, + }, + toMarkdown: { + ...schema.toMarkdown, + runner: (state, node) => { + state.addNode(CODE_MARKDOWN_TYPE, undefined, node.content.firstChild?.text ?? "", { + lang: node.attrs.language, + [CODE_FENCED_ATTRIBUTE_NAME]: readCodeFenced(node.attrs), + [BLOCK_ADJACENT_ATTRIBUTE_NAME]: readBlockAdjacent(node.attrs), + }); + }, + }, +}); diff --git a/src/features/editor/utils/createMilkdownEditor.ts b/src/features/editor/utils/createMilkdownEditor.ts index 9e7a102..d1c2f62 100644 --- a/src/features/editor/utils/createMilkdownEditor.ts +++ b/src/features/editor/utils/createMilkdownEditor.ts @@ -61,6 +61,7 @@ import { leafdownCharacterReferenceSchema, } from "../plugins/characterReference"; import { createLeafdownClipboardPlugin } from "../plugins/clipboard"; +import { createLeafdownCodeFormPlugin } from "../plugins/codeForm"; import { createLeafdownCommandKeymapPlugin } from "../plugins/commandKeymap"; import { createLeafdownCommandStatePlugin } from "../plugins/commandState"; import { @@ -104,7 +105,6 @@ import { } from "./bareAutolinkMarkdown"; import { withBlockquoteSeparator, - withCodeBlockSeparator, withFootnoteDefinitionSeparator, withParagraphSeparator, } from "./blockSeparatorMarkdown"; @@ -115,6 +115,7 @@ import { } from "./characterReferenceMarkdown"; import { createClipboardTextSerializer } from "./clipboard"; import { normalizeProseMirrorClipboardHtml } from "./clipboardHtml"; +import { serializeCode, withCodeForm } from "./codeMarkdown"; import { serializeHeading, withHeadingForm } from "./headingMarkdown"; import { createLeafdownHighlightParser } from "./highlighting"; import type { MarkdownLinkContext } from "./linkActivation"; @@ -223,6 +224,7 @@ export const createMilkdownEditor = async ({ .use(createLeafdownCharacterReferencePlugin()) .use(createLeafdownReferenceLinkPlugin()) .use(createLeafdownThematicBreakPlugin()) + .use(createLeafdownCodeFormPlugin()) .use(createLeafdownBlockStructurePlugin()) .use(createLeafdownMarkNestingPlugin()) .use(createLeafdownHeadingFormPlugin()) @@ -284,6 +286,7 @@ export const createMilkdownEditor = async ({ [BARE_AUTOLINK_MARKDOWN_TYPE]: serializeBareAutolink, [CHARACTER_REFERENCE_MARKDOWN_TYPE]: serializeCharacterReference, [RAW_HTML_MARKDOWN_TYPE]: serializeRawHtml, + code: serializeCode, definition: serializeMarkdownDefinition, heading: serializeHeading, image: serializeMarkdownImage, @@ -312,7 +315,7 @@ export const createMilkdownEditor = async ({ }, }; }); - // Every block carries the separator it was authored with, so the four Leafdown holds no + // Every block carries the separator it was authored with, so the three Leafdown holds no // other form for are wrapped here and the rest carry it alongside the form they already do. ctx.update( paragraphSchema.key, @@ -322,10 +325,6 @@ export const createMilkdownEditor = async ({ blockquoteSchema.key, (getSchema) => (schemaCtx) => withBlockquoteSeparator(getSchema(schemaCtx)), ); - ctx.update( - codeBlockSchema.key, - (getSchema) => (schemaCtx) => withCodeBlockSeparator(getSchema(schemaCtx)), - ); ctx.update( footnoteDefinitionSchema.key, (getSchema) => (schemaCtx) => withFootnoteDefinitionSeparator(getSchema(schemaCtx)), @@ -351,6 +350,10 @@ export const createMilkdownEditor = async ({ headingSchema.key, (getSchema) => (schemaCtx) => withHeadingForm(getSchema(schemaCtx)), ); + ctx.update( + codeBlockSchema.key, + (getSchema) => (schemaCtx) => withCodeForm(getSchema(schemaCtx)), + ); ctx.update(hardbreakSchema.key, (getSchema) => (schemaCtx) => ({ ...getSchema(schemaCtx), linebreakReplacement: true, From 5ca85dba90c347e8059824c21b85617739c27442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Thu, 3 Sep 2026 14:31:44 -0300 Subject: [PATCH 2/3] Keep the fence a code block was spelled with `mdast-util-to-markdown` spells every fence from one option for the whole document and sizes each run from the content it just wrote, so a tilde fence came back as a backtick fence, indentation and info-string spacing were dropped, and a fence the file left open gained a closing run. The tilde is the spelling that carries content the backtick cannot: an info string may hold a backtick only when the fence is spelled with tildes, so rewriting the fence forced the info string to be written as ``` and changed what another tool reads. Setting the option to the recorded character is what puts the literal backtick back, because the serializer marks a backtick unsafe only inside a grave-accent fence. The length is kept as the surplus over the shortest run that can hold the content, not as the run the file spelled. A fence has to outrun anything inside it, so the length is a floor the content can raise at any time, and recording the number itself made the record shift on the save that raised it. `corpus/commonmark/code.md` reaches that case without an edit: a backtick in a backtick info string opens no fence, so the rest of that file parses as a fence holding runs of its own, and the run has to widen. Measured against the same floor on the way back in, the surplus survives it. A fence is recorded open only where the block ends the document, which is the only place one can be written open. Recorded anywhere else it names a form the file can never hold, and the record flips on the save that closes it, which is what a fence left open inside a blockquote does. Indentation is measured against the document root, where the column the fence opens at is the indentation. Inside a container that column also counts the prefix the container wrote, and mdast names neither separately, so the prefix is taken from the narrowest line the block holds: CommonMark strips up to the fence's own indentation from each content line, which leaves that line spelling the prefix alone wherever one line was written without the indentation. A block indented uniformly reads the indentation as narrower and is written with less of it, which costs bytes rather than content, because CommonMark strips whatever the fence is written with back off on the way in. An indented fence no longer opens the line it is written on, so the pair of blocks it belongs to is measured against a line that can carry up to three spaces before the run. --- CHANGELOG.md | 2 + docs/decisions.md | 3 + src/features/editor/plugins/codeForm.ts | 49 ++- .../editor/tests/corpusRoundTrip.test.ts | 2 +- .../tests/markdownCompatibility.test.ts | 110 +++++++ .../editor/utils/blockSeparatorMarkdown.ts | 5 +- src/features/editor/utils/codeMarkdown.ts | 284 ++++++++++++++++-- 7 files changed, 419 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 726b39a..51cd9f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Keep the address of a URL or email address written on its own when a `*`, `_`, or `~` follows it, so text such as `https://example.com*` keeps its link pointing where it did. The backslash the file writes to keep that marker literal was being read back as part of the address, which gained another backslash every time the document was opened and saved. - Keep a URL or email address written on its own bare when a run shaped like a character reference but naming nothing, such as `¬arealentity;`, follows it, so text such as `https://example.com¬arealentity;` is saved as it was written instead of gaining angle brackets. Markdown leaves such a run outside the link whether or not the name exists. - Keep a URL or email address written on its own bare when a literal `<` or `>` sits beside it, so text such as `\` or `<https://example.com>` is saved as it was written, instead of putting angle brackets around it and saving `<<…>>`, which the next open reads as an angle-bracket URL between two literal brackets. +- Keep a code block written in the form it was authored, so a block indented with four spaces stays indented instead of being rewritten as a fenced block on the first save, at the top level, inside a list item, and inside a quote. Fencing an indented block did not stop at the block either: content holding a fence of its own forced a longer fence around it and rewrote the lines that followed. +- Keep the fence a code block was written with, so a `~~~` block stays a `~~~` block instead of becoming a ` ``` ` one, a fence written longer than it needs to be keeps its length, a fence indented up to three spaces keeps its indentation, and the spaces or tabs before an info string are kept. A `~~~` block may name a language containing a backtick where a ` ``` ` block may not, so rewriting the fence also rewrote that language as ``` and changed what other Markdown tools read. A block left unclosed at the end of the file stays unclosed, and is closed again as soon as anything follows it. A block made in the editor is still written with ` ``` `. - Keep a horizontal rule written the way it was authored, so `---`, `_ _ _`, or any other accepted run stays as it is instead of being rewritten as `***` on the first save. A rule inserted from the editor is still written as `***`, and so is one whose own run would be read back as a heading underline or as part of its list item's bullet. - Keep the form a reference definition was written in, so `[field report]: ` keeps the angle brackets around its destination and a definition whose title was written on the line below it keeps that line, instead of both being rewritten onto one bare line on the first save. The spaces or tabs written after the colon and before the title are kept as well. A destination that cannot be read back without angle brackets still gets them, and a definition the editor creates is written on one line. - Keep a reference link, a reference image, and the definitions they point at, instead of rewriting every reference as an inline link carrying its own copy of the destination and deleting the definition block on the first save. A definition now appears in the document as the line it is written as, and a reference shows that reference source when the caret reaches it. diff --git a/docs/decisions.md b/docs/decisions.md index a75d51f..e1f122f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -150,6 +150,9 @@ - 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 heading form is overridden. Its `heading` node carries only the level, which is all an ATX and a setext heading have in common, so both parse into the same node and are written back as an ATX heading with nothing closing it, rewriting every closed and every underlined heading in a file on its first save. Leafdown records the form on the node, decided in [issue #316](https://github.com/Azganoth/leafdown/issues/316), read from the slice of the file the node was built from: an ATX heading is one line, a setext heading ends on its underline, and only the second spans more than one, so the slice also names which form it holds. What is kept is the run closing an ATX heading, the spaces or tabs opening it, and the length of a setext underline; the underline's own character answers for the level rather than the file, so a heading moved between levels one and two is underlined by the character that level reads back as. `mdast-util-to-markdown` settles both forms from one option for the whole document and sizes each run from the content it just wrote, so the option carries the authored form for the length of the heading and the runs are put back on the handler's own output. A heading the editor creates is written as ATX with one space and nothing closing it, which is also what a recorded form gives way to where the lines it lands on would not be read back as the heading: a setext underline carries only levels one and two, and a setext heading written after a paragraph in a tight list item is joined to it by a single newline, which leaves its content read as more of that paragraph and its underline covering both. The blank line the serializer writes between two headings belongs to the blank-line class rather than to this one, so `corpus/commonmark/blocks.md` loses its heading-form differences without reaching byte identity. - 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 single code block form is overridden. Its `code` node carries a value and an info string, which is all an indented and a fenced block have in common, and `mdast-util-to-markdown` picks one form and one fence spelling for the whole document, so every indented block was rewritten as a backtick fence and every tilde fence rewritten as a backtick one. Leafdown records the form on the node, decided in [issue #321](https://github.com/Azganoth/leafdown/issues/321) for the choice between the two forms and [issue #320](https://github.com/Azganoth/leafdown/issues/320) for the way a fence is spelled, read from the slice of the file the node was built from: an indented block's slice opens on the line its indentation is written on and a fence's opens at the fence itself, so indented code can never stand on a fence run and the head of the slice names the form. What is kept for a fence is the character, the spacing before the info string, the indentation up to the three spaces CommonMark still reads a fence under, and whether the file closed it. The tilde is the spelling that carries content the backtick cannot: an info string may hold a backtick only when the fence is spelled with tildes, so rewriting the fence forced the info string to be written as ``` and changed what another tool reads. A block the editor creates is written as a backtick fence, which is also what a recorded form gives way to where the block can no longer be written in it: an indented block cannot carry an info string, open or close on a blank line, or hold nothing but whitespace, and a fence left open runs to the end of the file, so a block that stops ending the document is closed. A fence is recorded open only where the block ends the document, which is the only place one can be written open: recording it anywhere else records a form the file can never hold, and the record flips on the save that closes it, which is what a fence the file leaves open inside a blockquote does. +- A fence's length is kept as the surplus over the shortest run that can hold its content rather than as the run the file spelled. A fence has to outrun anything inside it, so the length is a floor the content can raise at any time, and recording the number itself made the record shift whenever an edit — or a file whose own parse leaves a fence unclosed, as `corpus/commonmark/code.md` does — forced a wider run than the file was written with. The surplus survives that, because it is measured against the same floor on the way back in. +- A fence's indentation is measured against the document root, where the column the fence opens at is the indentation. Inside a container that column also counts the prefix the container wrote, and mdast names neither separately, so the prefix is taken from the narrowest line the block holds: CommonMark strips up to the fence's own indentation from each content line, which makes that line the prefix alone wherever one line was written without the indentation. A block whose every line keeps some of it reads the indentation as narrower and is written with less of it, which costs bytes rather than content, because CommonMark strips whatever indentation the fence is written with back off on the way in. - 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. diff --git a/src/features/editor/plugins/codeForm.ts b/src/features/editor/plugins/codeForm.ts index 619e241..1417e15 100644 --- a/src/features/editor/plugins/codeForm.ts +++ b/src/features/editor/plugins/codeForm.ts @@ -2,30 +2,55 @@ import type { MarkdownNode } from "@milkdown/kit/transformer"; import { $remark } from "@milkdown/kit/utils"; import { + CODE_CLOSED_ATTRIBUTE_NAME, + CODE_FENCE_ATTRIBUTE_NAME, + CODE_FENCE_SURPLUS_ATTRIBUTE_NAME, CODE_FENCED_ATTRIBUTE_NAME, + CODE_INDENT_ATTRIBUTE_NAME, CODE_MARKDOWN_TYPE, - findCodeFenced, + CODE_SEPARATOR_ATTRIBUTE_NAME, + findCodeForm, } from "../utils/codeMarkdown"; -// The parse records the value and the info string, and the two forms of code block agree on both, -// so the form survives only in the slice of the file the node was built from. A node the parser -// gave no position keeps the default. -const markAuthoredForm = (node: MarkdownNode, source: string) => { - for (const child of node.children ?? []) { +// The parse records the value and the info string, and neither the two forms of code block nor the +// fences that spell one differ in either, so the form survives only in the slice of the file the +// node was built from. A node the parser gave no position keeps the defaults. +const markAuthoredForm = (node: MarkdownNode, source: string, atRoot: boolean) => { + const children = node.children ?? []; + + for (const child of children) { const start = child.position?.start.offset; const end = child.position?.end.offset; + const column = child.position?.start.column; + + if ( + child.type === CODE_MARKDOWN_TYPE && + start !== undefined && + end !== undefined && + column !== undefined + ) { + const form = findCodeForm({ + raw: source.slice(start, end), + value: (child.value as string | undefined) ?? "", + column, + atRoot, + endsDocument: atRoot && child === children[children.length - 1], + }); + const authored = child as Record; - if (child.type === CODE_MARKDOWN_TYPE && start !== undefined && end !== undefined) { - (child as Record)[CODE_FENCED_ATTRIBUTE_NAME] = findCodeFenced( - source.slice(start, end), - ); + authored[CODE_FENCED_ATTRIBUTE_NAME] = form.fenced; + authored[CODE_FENCE_ATTRIBUTE_NAME] = form.fence; + authored[CODE_FENCE_SURPLUS_ATTRIBUTE_NAME] = form.fenceSurplus; + authored[CODE_SEPARATOR_ATTRIBUTE_NAME] = form.separator; + authored[CODE_INDENT_ATTRIBUTE_NAME] = form.indent; + authored[CODE_CLOSED_ATTRIBUTE_NAME] = form.closed; } - markAuthoredForm(child, source); + markAuthoredForm(child, source, false); } }; export const createLeafdownCodeFormPlugin = () => $remark("leafdownCodeForm", () => () => (tree, file) => { - markAuthoredForm(tree as MarkdownNode, String(file)); + markAuthoredForm(tree as MarkdownNode, String(file), true); }); diff --git a/src/features/editor/tests/corpusRoundTrip.test.ts b/src/features/editor/tests/corpusRoundTrip.test.ts index 94a385b..dac8424 100644 --- a/src/features/editor/tests/corpusRoundTrip.test.ts +++ b/src/features/editor/tests/corpusRoundTrip.test.ts @@ -16,6 +16,7 @@ const byteIdenticalFiles = [ "commonmark/html.md", "gfm/tagfilter.md", "isolated/end-of-file/incomplete-html-comment.md", + "isolated/end-of-file/unclosed-code-fence.md", "isolated/end-of-file/unclosed-directive.md", "isolated/end-of-file/unclosed-html-block.md", ]; @@ -33,7 +34,6 @@ const convergingFiles = [ "gfm/strikethrough.md", "gfm/tables.md", "gfm/task-lists.md", - "isolated/end-of-file/unclosed-code-fence.md", ]; const corpusFiles = [...byteIdenticalFiles, ...convergingFiles]; diff --git a/src/features/editor/tests/markdownCompatibility.test.ts b/src/features/editor/tests/markdownCompatibility.test.ts index ea0fc11..4d32254 100644 --- a/src/features/editor/tests/markdownCompatibility.test.ts +++ b/src/features/editor/tests/markdownCompatibility.test.ts @@ -1262,6 +1262,116 @@ describe("Code block form", () => { expect(mounted.getMarkdown()).toBe("```\n\nindented\n```\n"); }); + + it.each([ + // The fence character is the one the file spelled, and a tilde fence's info string may hold a + // backtick where a backtick fence's may not. + "~~~\ntilde fence\n~~~", + "~~~ language`with-backtick\nvalid tilde info string\n~~~", + // A run longer than the content needs is the length the file was written at. + "````\nplain\n````", + "~~~~~\nplain\n~~~~~", + // The spacing between the run and the info string is the file's. + "``` language+escaped\nvalid backtick info string\n```", + "```\tafter-tab\nvalid\n```", + // A fence stays under the three spaces CommonMark still reads it under. + " ```\n three leading spaces still open a fence\n ```", + " ```\n one leading space\n ```", + // A blank line inside a container says nothing about the prefix the container wrote, so the + // indentation is measured off the lines that do. + "> ```\n>\n> quoted with a blank line\n> ```", + // A fence holding nothing stands on one line, which is the whole of the block. + "```", + // A list item's own padding puts its content past the marker, so a block written against that + // boundary carries no indentation of its own and the item's form answers for the whole column. + "- ```\n item and indented\n ```", + ])("writes the fence in %j as it was authored", async (source) => { + const mounted = await mountEditor(`${source}\n`); + + expect(mounted.getMarkdown()).toBe(`${source}\n`); + }); + + // mdast names a container's prefix and a fence's own indentation as one column, and CommonMark + // strips up to that indentation from each content line, so the two come apart only where some + // line was written without it. A block indented uniformly reads the prefix as the whole column + // and is written with less indentation than the file gave it, which CommonMark strips back off + // on the way in, so the block still reopens as itself. + it("writes a uniformly indented quoted fence with the indentation its own lines account for", async () => { + const mounted = await mountEditor("> ```\n> quoted and indented\n> ```\n"); + const written = mounted.getMarkdown(); + + expect(written).toBe("> ```\n> quoted and indented\n> ```\n"); + expect((await mountEditor(written)).view.state.doc.toJSON()).toEqual( + mounted.view.state.doc.toJSON(), + ); + }); + + // A run has to outrun anything inside it, so the length the file wrote is a floor the content can + // still raise rather than a number written back whatever the block now holds. + it("raises a recorded run the content has outgrown", async () => { + const mounted = await mountEditor("```\nplain\n```\n"); + const { view } = mounted; + + view.dispatch(view.state.tr.insertText("\n```", 6)); + + expect(mounted.getMarkdown()).toBe("````\nplain\n```\n````\n"); + }); + + // The surplus over that floor is what the file spent, so a wide fence stays wide when its content + // grows into the run the file wrote. + it("keeps a recorded surplus above the run the content needs", async () => { + const mounted = await mountEditor("`````\nplain\n`````\n"); + const { view } = mounted; + + view.dispatch(view.state.tr.insertText("\n```", 6)); + + expect(mounted.getMarkdown()).toBe("``````\nplain\n```\n``````\n"); + }); + + // A file ending on its opening fence writes that fence as the whole of the block, so the run has + // to be read as the one that opened it rather than as one closing it. + it("writes a fence standing alone with no final newline unclosed", async () => { + const mounted = await mountEditor("```"); + + expect(mounted.getMarkdown()).toBe("```\n"); + }); + + it("writes a fence left unclosed at end of file unclosed", async () => { + const source = "# Heading\n\n```\nThe code block continues through end of file.\n"; + const mounted = await mountEditor(source); + + expect(mounted.getMarkdown()).toBe(source); + }); + + // A fence can be left open only where the block ends the document, so one the file left open + // inside a container is closed on the way out and recorded closed. Recording it open would record + // a form the file can never be written in, and the record would flip on the save that closes it. + it("closes a fence the file left open inside a blockquote", async () => { + const mounted = await mountEditor("> ```\n> code\n\nAfter.\n"); + const written = mounted.getMarkdown(); + + expect(written).toBe("> ```\n> code\n> ```\n\nAfter.\n"); + expect((await mountEditor(written)).view.state.doc.toJSON()).toEqual( + mounted.view.state.doc.toJSON(), + ); + }); + + // A fence left open runs to the end of the file, so a block that stops ending the document has to + // be closed or it reads the blocks after it as its own content. + it("closes an unclosed fence once a block follows it", async () => { + const mounted = await mountEditor("```\ncode\n"); + const { view } = mounted; + const { paragraph } = view.state.schema.nodes; + + view.dispatch( + view.state.tr.insert( + view.state.doc.content.size, + paragraph.create(null, [view.state.schema.text("After")]), + ), + ); + + expect(mounted.getMarkdown()).toBe("```\ncode\n```\n\nAfter\n"); + }); }); describe("Table outer pipe form", () => { diff --git a/src/features/editor/utils/blockSeparatorMarkdown.ts b/src/features/editor/utils/blockSeparatorMarkdown.ts index 04669ac..4ad2a92 100644 --- a/src/features/editor/utils/blockSeparatorMarkdown.ts +++ b/src/features/editor/utils/blockSeparatorMarkdown.ts @@ -47,8 +47,9 @@ const TRAILING_WHITESPACE_PATTERN = /[\t ]+$/u; // wrote it, without one. const ATX_HEADING_PATTERN = /^#{1,6}(?:[\t ]|$)/u; // An info string cannot hold a backtick when the fence is spelled with them, which is the one case -// where a run of three opens no block. -const CODE_FENCE_PATTERN = /^(?:`{3,}[^`]*|~{3,}.*)$/u; +// where a run of three opens no block. A fence carries whatever indentation it was authored with, +// and CommonMark still reads one under three spaces, so the run is not always first on the line. +const CODE_FENCE_PATTERN = /^ {0,3}(?:`{3,}[^`]*|~{3,}.*)$/u; const BLOCKQUOTE_PATTERN = /^>/u; // A marker interrupts only where the item it opens holds content on the marker's own line, and an // ordered list interrupts only where it starts at one. diff --git a/src/features/editor/utils/codeMarkdown.ts b/src/features/editor/utils/codeMarkdown.ts index 06094b8..3c6fb7b 100644 --- a/src/features/editor/utils/codeMarkdown.ts +++ b/src/features/editor/utils/codeMarkdown.ts @@ -14,6 +14,8 @@ type RemarkStringifyHandlers = NonNullable< type StringifyState = Parameters>[2]; +type StringifyParent = Parameters>[1]; + type JoinArguments = Parameters; // Milkdown types a stringify handler's node as `any`, so the block is named here from the blocks @@ -22,63 +24,293 @@ type CodeNode = Extract; export const CODE_MARKDOWN_TYPE = "code"; export const CODE_FENCED_ATTRIBUTE_NAME = "fenced"; +export const CODE_FENCE_ATTRIBUTE_NAME = "fence"; +export const CODE_FENCE_SURPLUS_ATTRIBUTE_NAME = "fenceSurplus"; +export const CODE_SEPARATOR_ATTRIBUTE_NAME = "codeSeparator"; +export const CODE_INDENT_ATTRIBUTE_NAME = "codeIndent"; +export const CODE_CLOSED_ATTRIBUTE_NAME = "closed"; + +const ROOT_MARKDOWN_TYPE = "root"; + +export type CodeFence = "`" | "~"; // The form a block is written in when it has none of its own: one the editor created, and one // whose authored form cannot be recovered. A fence is the form that carries every block, because -// it is the only one an info string can be written on. +// it is the only one an info string can be written on; backticks no longer than the content needs, +// opened directly onto that info string, at column zero, and closed. export const DEFAULT_CODE_FENCED = true; +export const DEFAULT_CODE_FENCE: CodeFence = "`"; +export const DEFAULT_CODE_FENCE_LENGTH = 3; +export const DEFAULT_CODE_FENCE_SURPLUS = 0; +export const DEFAULT_CODE_SEPARATOR = ""; +export const DEFAULT_CODE_INDENT = 0; +export const DEFAULT_CODE_CLOSED = true; + +// Four spaces open indented code instead, so three is the widest a fence can be indented by. +const CODE_INDENT_MAX = 3; // A fence opens on three or more of one character. Indented code opens on the four spaces that // make it, so a slice standing on a run of either character was written as a fence. const CODE_FENCE_PATTERN = /^(?:`{3,}|~{3,})/u; +// The opening run, the spacing after it, and whether an info string follows that spacing. The +// spacing is the separator only where something follows it; a run closing its line carries +// line-final whitespace, which is not the block's to write. +const CODE_FENCE_HEAD_PATTERN = /^(`{3,}|~{3,})([\t ]*)(\S)?/u; +// A closing fence carries its run and nothing else. Only the prefix a container wrote and the +// block's own indentation stand before it, and neither says anything the run does not. +const CODE_FENCE_CLOSING_PATTERN = /^[\t >]*(`{3,}|~{3,})[\t ]*$/u; +// The run the handler wrote, which the recorded surplus is added to rather than replacing. +const WRITTEN_CODE_FENCE_PATTERN = /^(`+|~+)/u; + +const CODE_SEPARATOR_PATTERN = /^[\t ]*$/u; + +export interface AuthoredCodeForm { + fenced: boolean; + fence: CodeFence; + fenceSurplus: number; + separator: string; + indent: number; + closed: boolean; +} + +const INDENTED_CODE_FORM: AuthoredCodeForm = { + fenced: false, + fence: DEFAULT_CODE_FENCE, + fenceSurplus: DEFAULT_CODE_FENCE_SURPLUS, + separator: DEFAULT_CODE_SEPARATOR, + indent: DEFAULT_CODE_INDENT, + closed: DEFAULT_CODE_CLOSED, +}; export const readCodeFenced = (source: object): boolean => (source as Record)[CODE_FENCED_ATTRIBUTE_NAME] !== false; +export const readCodeFence = (source: object): CodeFence => { + const fence = (source as Record)[CODE_FENCE_ATTRIBUTE_NAME]; + + return fence === "`" || fence === "~" ? fence : DEFAULT_CODE_FENCE; +}; + +export const readCodeFenceSurplus = (source: object): number => { + const surplus = (source as Record)[CODE_FENCE_SURPLUS_ATTRIBUTE_NAME]; + + return typeof surplus === "number" && Number.isInteger(surplus) && surplus > 0 + ? surplus + : DEFAULT_CODE_FENCE_SURPLUS; +}; + +// The longest run of the fence's own character the content holds, which is what a fence has to +// outrun to hold it. +const findLongestRun = (value: string, fence: CodeFence) => { + let longest = 0; + let current = 0; + + for (const character of value) { + current = character === fence ? current + 1 : 0; + longest = Math.max(longest, current); + } + + return longest; +}; + +// The shortest run that can hold the content, which is the length the serializer arrives at on its +// own and the floor the authored length is measured against. +const findRequiredFenceLength = (value: string, fence: CodeFence) => + Math.max(findLongestRun(value, fence) + 1, DEFAULT_CODE_FENCE_LENGTH); + +export const readCodeSeparator = (source: object): string => { + const separator = (source as Record)[CODE_SEPARATOR_ATTRIBUTE_NAME]; + + return typeof separator === "string" && CODE_SEPARATOR_PATTERN.test(separator) + ? separator + : DEFAULT_CODE_SEPARATOR; +}; + +export const readCodeIndent = (source: object): number => { + const indent = (source as Record)[CODE_INDENT_ATTRIBUTE_NAME]; + + return typeof indent === "number" && Number.isInteger(indent) && indent > 0 + ? Math.min(indent, CODE_INDENT_MAX) + : DEFAULT_CODE_INDENT; +}; + +export const readCodeClosed = (source: object): boolean => + (source as Record)[CODE_CLOSED_ATTRIBUTE_NAME] !== false; + +// A container writes the same prefix onto every line the block holds, and CommonMark strips up to +// the fence's own indentation from each content line, so the narrowest content line answers for +// that prefix wherever one line was written without the indentation. A block whose every line +// keeps some of it reads the prefix as wider and the indentation as narrower, which writes the +// block back with less indentation than the file gave it rather than with a prefix the container +// never wrote. A blank line spells neither and is passed over. +const findContainerPrefixWidth = (raw: string, value: string) => { + const lines = raw.split("\n"); + const contents = value === "" ? [] : value.split("\n"); + let width: number | undefined; + + for (const [index, content] of contents.entries()) { + const line = lines[index + 1]; + + if (line === undefined || content === "") { + continue; + } + + const measured = line.length - content.length; + + width = width === undefined ? measured : Math.min(width, measured); + } + + return width; +}; + +// A fence's slice opens at the fence itself, past whatever indentation the file gave it, so the +// indentation is read off the column instead. At the document root that column is the indentation; +// inside a container it also counts the prefix the container wrote, which only the block's own +// lines separate out. +const findFenceIndent = (raw: string, column: number, atRoot: boolean, value: string) => { + const offset = column - 1; + + if (offset <= 0) { + return DEFAULT_CODE_INDENT; + } + + const prefix = atRoot ? 0 : findContainerPrefixWidth(raw, value); + + return prefix === undefined + ? DEFAULT_CODE_INDENT + : Math.min(Math.max(offset - prefix, 0), CODE_INDENT_MAX); +}; + +// A fence the file never closed runs to the end of the block, so the slice ends on content rather +// than on a run of its own. A run shorter than the one that opened the block closes nothing, which +// is what keeps a fence written inside the content from reading as the end of it. +const findFenceClosed = (raw: string, fence: string, length: number) => { + const lines = raw.split("\n"); + + if (lines.length < 2) { + return false; + } + + const closing = CODE_FENCE_CLOSING_PATTERN.exec(lines[lines.length - 1] ?? ""); + + return closing !== null && closing[1].charAt(0) === fence && closing[1].length >= length; +}; + +export interface CodeFormSource { + // The slice of the file the node was built from. + raw: string; + // The value the parse kept from it, whose lines run against the slice's own. + value: string; + column: number; + atRoot: boolean; + // Whether the block stands last in the document, which is the only place a fence can be left + // open. Recording an open fence anywhere else would record a form the file can never be written + // in, and the record would flip on the save that closes it. + endsDocument: boolean; +} + // An indented block's slice opens at the line its indentation is written on, and a fence's opens -// at the fence itself, past whatever indentation the file gave it. Neither the value nor the info -// string says which form held the block, so the head of that slice is what names it: indented code -// can never stand on a fence run, because the four spaces that open it stand there first. -export const findCodeFenced = (raw: string): boolean => CODE_FENCE_PATTERN.test(raw); +// at the fence itself, whatever the container or the indentation before it, so the head of that +// slice is what names the form: indented code can never stand on a fence run, because the four +// spaces that open it stand there first. +export const findCodeForm = ({ + raw, + value, + column, + atRoot, + endsDocument, +}: CodeFormSource): AuthoredCodeForm => { + const head = CODE_FENCE_PATTERN.test(raw) ? CODE_FENCE_HEAD_PATTERN.exec(raw) : null; + + if (head === null) { + return INDENTED_CODE_FORM; + } + + const [, run, spacing, info] = head; + const fence = run.charAt(0) as CodeFence; + + return { + fenced: true, + fence, + fenceSurplus: Math.max(run.length - findRequiredFenceLength(value, fence), 0), + separator: info === undefined ? DEFAULT_CODE_SEPARATOR : spacing, + indent: findFenceIndent(raw, column, atRoot, value), + closed: !endsDocument || findFenceClosed(raw, fence, run.length), + }; +}; + +// A fence left open runs to the end of the file, so the form is written back only where the block +// ends the document. Anything after it would be read as the code the block holds. +const standsLastInDocument = (node: CodeNode, parent: StringifyParent) => + parent?.type === ROOT_MARKDOWN_TYPE && parent.children[parent.children.length - 1] === node; + +// The handler sizes the run to the content it just wrote, and a fence has to outrun anything +// inside it, so the file's own length is kept as the surplus over that floor rather than as a +// number that could fall under it. Content edited to hold a longer run raises the floor and +// carries the surplus with it. The indentation and the closing run stand outside what the handler +// tracks, so both are put back on its own output, and the block's own lines carry the indentation +// because CommonMark strips it from them again on the way back in. +const withFenceForm = (value: string, run: string, node: CodeNode, parent: StringifyParent) => { + const lines = value.split("\n"); + const fence = run.charAt(0).repeat(run.length + readCodeFenceSurplus(node)); + const indent = " ".repeat(readCodeIndent(node)); + const info = (lines[0] ?? "").slice(run.length); + const opening = info === "" ? fence : fence + readCodeSeparator(node) + info; + const content = lines.slice(1, -1).map((line) => (line === "" ? "" : indent + line)); + const written = [indent + opening, ...content]; + + return ( + readCodeClosed(node) || !standsLastInDocument(node, parent) + ? [...written, indent + fence] + : written + ).join("\n"); +}; // `mdast-util-to-markdown` chooses between the two code forms from one option for the whole -// document, and its indented branch also holds the conditions CommonMark puts on that form: a -// block carrying an info string, opening or closing on a blank line, or holding nothing but -// whitespace cannot be written indented, and is fenced whatever the file wrote. The choice is -// reachable only through that option, so it carries the authored form for the length of the block. +// document and spells a fence from another, and its indented branch also holds the conditions +// CommonMark puts on that form: a block carrying an info string, opening or closing on a blank +// line, or holding nothing but whitespace cannot be written indented, and is fenced whatever the +// file wrote. Both choices are reachable only through those options, so they carry the authored +// form for the length of the block and the runs are put back on the handler's own output. export const serializeCode: NonNullable = ( node: CodeNode, parent, state, info, ) => { - const { fences } = state.options; + const { fence, fences } = state.options; state.options.fences = readCodeFenced(node); + state.options.fence = readCodeFence(node); try { - return defaultHandlers.code(node, parent, state, info); + const value = defaultHandlers.code(node, parent, state, info); + const written = WRITTEN_CODE_FENCE_PATTERN.exec(value); + + return written ? withFenceForm(value, written[1], node, parent) : value; } finally { - state.options.fences = fences; + Object.assign(state.options, { fence, fences }); } }; // The preset's own runner opens the mdast node itself and carries only the info string, so it is // replaced rather than wrapped: the authored form has to reach the node the runner opens. The -// separator every block carries travels with it, the way each block Leafdown holds another form for -// carries it in that form's own module. +// separator every block carries travels with it, the way each block Leafdown holds another form +// for carries it in that form's own module. export const withCodeForm = (schema: NodeSchema): NodeSchema => ({ ...schema, attrs: { ...schema.attrs, - [CODE_FENCED_ATTRIBUTE_NAME]: { - default: DEFAULT_CODE_FENCED, - validate: "boolean", - }, - [BLOCK_ADJACENT_ATTRIBUTE_NAME]: { - default: DEFAULT_BLOCK_ADJACENT, - validate: "boolean", + [CODE_FENCED_ATTRIBUTE_NAME]: { default: DEFAULT_CODE_FENCED, validate: "boolean" }, + [CODE_FENCE_ATTRIBUTE_NAME]: { default: DEFAULT_CODE_FENCE, validate: "string" }, + [CODE_FENCE_SURPLUS_ATTRIBUTE_NAME]: { + default: DEFAULT_CODE_FENCE_SURPLUS, + validate: "number", }, + [CODE_SEPARATOR_ATTRIBUTE_NAME]: { default: DEFAULT_CODE_SEPARATOR, validate: "string" }, + [CODE_INDENT_ATTRIBUTE_NAME]: { default: DEFAULT_CODE_INDENT, validate: "number" }, + [CODE_CLOSED_ATTRIBUTE_NAME]: { default: DEFAULT_CODE_CLOSED, validate: "boolean" }, + [BLOCK_ADJACENT_ATTRIBUTE_NAME]: { default: DEFAULT_BLOCK_ADJACENT, validate: "boolean" }, }, parseMarkdown: { ...schema.parseMarkdown, @@ -88,6 +320,11 @@ export const withCodeForm = (schema: NodeSchema): NodeSchema => ({ state.openNode(type, { language: node.lang ?? "", [CODE_FENCED_ATTRIBUTE_NAME]: readCodeFenced(node), + [CODE_FENCE_ATTRIBUTE_NAME]: readCodeFence(node), + [CODE_FENCE_SURPLUS_ATTRIBUTE_NAME]: readCodeFenceSurplus(node), + [CODE_SEPARATOR_ATTRIBUTE_NAME]: readCodeSeparator(node), + [CODE_INDENT_ATTRIBUTE_NAME]: readCodeIndent(node), + [CODE_CLOSED_ATTRIBUTE_NAME]: readCodeClosed(node), [BLOCK_ADJACENT_ATTRIBUTE_NAME]: readBlockAdjacent(node), }); @@ -104,6 +341,11 @@ export const withCodeForm = (schema: NodeSchema): NodeSchema => ({ state.addNode(CODE_MARKDOWN_TYPE, undefined, node.content.firstChild?.text ?? "", { lang: node.attrs.language, [CODE_FENCED_ATTRIBUTE_NAME]: readCodeFenced(node.attrs), + [CODE_FENCE_ATTRIBUTE_NAME]: readCodeFence(node.attrs), + [CODE_FENCE_SURPLUS_ATTRIBUTE_NAME]: readCodeFenceSurplus(node.attrs), + [CODE_SEPARATOR_ATTRIBUTE_NAME]: readCodeSeparator(node.attrs), + [CODE_INDENT_ATTRIBUTE_NAME]: readCodeIndent(node.attrs), + [CODE_CLOSED_ATTRIBUTE_NAME]: readCodeClosed(node.attrs), [BLOCK_ADJACENT_ATTRIBUTE_NAME]: readBlockAdjacent(node.attrs), }); }, From fd5a83303aae3c211e7561eb12a69ca259719100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Thu, 3 Sep 2026 15:03:29 -0300 Subject: [PATCH 3/3] End an open fence with the block that holds it A fence is ended by whatever ends the block it stands in, not only by the end of the file, so one the file leaves open at the end of a blockquote, a list item, or a footnote definition can be written open and the blocks after that container still read outside the code. Closing it there spent a rewrite on a form the file already held, and it was the one code-block difference Typora 1.14.9 does not produce. Deriving a container's prefix from the block's own lines is withdrawn. It assumed the container writes the same prefix on the line the block opens on as on the lines under it, which a footnote definition does not: it writes its label on the first and indents the rest by four, so the difference read as indentation the file never wrote and was written into the code the block holds. The corpus reaches no fence inside a footnote definition, so nothing guarding it caught this. A form axis that can reach the content is narrower than one that cannot, so a fence keeps its indentation only at the document root, where the column it opens at is the indentation and nothing else. That leaves a quoted indented fence written flush, which costs bytes rather than content. --- CHANGELOG.md | 2 +- docs/decisions.md | 4 +- src/features/editor/plugins/codeForm.ts | 2 +- .../tests/markdownCompatibility.test.ts | 31 ++++++++-- src/features/editor/utils/codeMarkdown.ts | 57 ++++--------------- 5 files changed, 40 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51cd9f9..3f20214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Keep a URL or email address written on its own bare when a run shaped like a character reference but naming nothing, such as `¬arealentity;`, follows it, so text such as `https://example.com¬arealentity;` is saved as it was written instead of gaining angle brackets. Markdown leaves such a run outside the link whether or not the name exists. - Keep a URL or email address written on its own bare when a literal `<` or `>` sits beside it, so text such as `\` or `<https://example.com>` is saved as it was written, instead of putting angle brackets around it and saving `<<…>>`, which the next open reads as an angle-bracket URL between two literal brackets. - Keep a code block written in the form it was authored, so a block indented with four spaces stays indented instead of being rewritten as a fenced block on the first save, at the top level, inside a list item, and inside a quote. Fencing an indented block did not stop at the block either: content holding a fence of its own forced a longer fence around it and rewrote the lines that followed. -- Keep the fence a code block was written with, so a `~~~` block stays a `~~~` block instead of becoming a ` ``` ` one, a fence written longer than it needs to be keeps its length, a fence indented up to three spaces keeps its indentation, and the spaces or tabs before an info string are kept. A `~~~` block may name a language containing a backtick where a ` ``` ` block may not, so rewriting the fence also rewrote that language as ``` and changed what other Markdown tools read. A block left unclosed at the end of the file stays unclosed, and is closed again as soon as anything follows it. A block made in the editor is still written with ` ``` `. +- Keep the fence a code block was written with, so a `~~~` block stays a `~~~` block instead of becoming a ` ``` ` one, a fence written longer than it needs to be keeps its length, a fence indented up to three spaces keeps its indentation, and the spaces or tabs before an info string are kept. A `~~~` block may name a language containing a backtick where a ` ``` ` block may not, so rewriting the fence also rewrote that language as ``` and changed what other Markdown tools read. A block left unclosed at the end of the file stays unclosed, and so does one left unclosed at the end of a quote, a list item, or a footnote definition, because the block around it is what ends it; it is closed again as soon as something follows it inside that block. A block made in the editor is still written with ` ``` `. - Keep a horizontal rule written the way it was authored, so `---`, `_ _ _`, or any other accepted run stays as it is instead of being rewritten as `***` on the first save. A rule inserted from the editor is still written as `***`, and so is one whose own run would be read back as a heading underline or as part of its list item's bullet. - Keep the form a reference definition was written in, so `[field report]: ` keeps the angle brackets around its destination and a definition whose title was written on the line below it keeps that line, instead of both being rewritten onto one bare line on the first save. The spaces or tabs written after the colon and before the title are kept as well. A destination that cannot be read back without angle brackets still gets them, and a definition the editor creates is written on one line. - Keep a reference link, a reference image, and the definitions they point at, instead of rewriting every reference as an inline link carrying its own copy of the destination and deleting the definition block on the first save. A definition now appears in the document as the line it is written as, and a reference shows that reference source when the caret reaches it. diff --git a/docs/decisions.md b/docs/decisions.md index e1f122f..5883377 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -150,9 +150,9 @@ - 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 heading form is overridden. Its `heading` node carries only the level, which is all an ATX and a setext heading have in common, so both parse into the same node and are written back as an ATX heading with nothing closing it, rewriting every closed and every underlined heading in a file on its first save. Leafdown records the form on the node, decided in [issue #316](https://github.com/Azganoth/leafdown/issues/316), read from the slice of the file the node was built from: an ATX heading is one line, a setext heading ends on its underline, and only the second spans more than one, so the slice also names which form it holds. What is kept is the run closing an ATX heading, the spaces or tabs opening it, and the length of a setext underline; the underline's own character answers for the level rather than the file, so a heading moved between levels one and two is underlined by the character that level reads back as. `mdast-util-to-markdown` settles both forms from one option for the whole document and sizes each run from the content it just wrote, so the option carries the authored form for the length of the heading and the runs are put back on the handler's own output. A heading the editor creates is written as ATX with one space and nothing closing it, which is also what a recorded form gives way to where the lines it lands on would not be read back as the heading: a setext underline carries only levels one and two, and a setext heading written after a paragraph in a tight list item is joined to it by a single newline, which leaves its content read as more of that paragraph and its underline covering both. The blank line the serializer writes between two headings belongs to the blank-line class rather than to this one, so `corpus/commonmark/blocks.md` loses its heading-form differences without reaching byte identity. - 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 single code block form is overridden. Its `code` node carries a value and an info string, which is all an indented and a fenced block have in common, and `mdast-util-to-markdown` picks one form and one fence spelling for the whole document, so every indented block was rewritten as a backtick fence and every tilde fence rewritten as a backtick one. Leafdown records the form on the node, decided in [issue #321](https://github.com/Azganoth/leafdown/issues/321) for the choice between the two forms and [issue #320](https://github.com/Azganoth/leafdown/issues/320) for the way a fence is spelled, read from the slice of the file the node was built from: an indented block's slice opens on the line its indentation is written on and a fence's opens at the fence itself, so indented code can never stand on a fence run and the head of the slice names the form. What is kept for a fence is the character, the spacing before the info string, the indentation up to the three spaces CommonMark still reads a fence under, and whether the file closed it. The tilde is the spelling that carries content the backtick cannot: an info string may hold a backtick only when the fence is spelled with tildes, so rewriting the fence forced the info string to be written as ``` and changed what another tool reads. A block the editor creates is written as a backtick fence, which is also what a recorded form gives way to where the block can no longer be written in it: an indented block cannot carry an info string, open or close on a blank line, or hold nothing but whitespace, and a fence left open runs to the end of the file, so a block that stops ending the document is closed. A fence is recorded open only where the block ends the document, which is the only place one can be written open: recording it anywhere else records a form the file can never hold, and the record flips on the save that closes it, which is what a fence the file leaves open inside a blockquote does. +- The preset's single code block form is overridden. Its `code` node carries a value and an info string, which is all an indented and a fenced block have in common, and `mdast-util-to-markdown` picks one form and one fence spelling for the whole document, so every indented block was rewritten as a backtick fence and every tilde fence rewritten as a backtick one. Leafdown records the form on the node, decided in [issue #321](https://github.com/Azganoth/leafdown/issues/321) for the choice between the two forms and [issue #320](https://github.com/Azganoth/leafdown/issues/320) for the way a fence is spelled, read from the slice of the file the node was built from: an indented block's slice opens on the line its indentation is written on and a fence's opens at the fence itself, so indented code can never stand on a fence run and the head of the slice names the form. What is kept for a fence is the character, the spacing before the info string, the indentation up to the three spaces CommonMark still reads a fence under, and whether the file closed it. The tilde is the spelling that carries content the backtick cannot: an info string may hold a backtick only when the fence is spelled with tildes, so rewriting the fence forced the info string to be written as ``` and changed what another tool reads. A block the editor creates is written as a backtick fence, which is also what a recorded form gives way to where the block can no longer be written in it: an indented block cannot carry an info string, open or close on a blank line, or hold nothing but whitespace, and an open fence runs to the end of the block that holds it, so a block that stops standing last there is closed. A fence is recorded open only where it stands last in its container, which is the only place one can be written open: recorded anywhere else it names a form the file can never hold, and the record flips on the save that closes it. The container is what ends it, so a fence the file leaves open at the end of a blockquote, a list item, or a footnote definition stays open and the blocks after that container stay outside the code, which is the reading `corpus/interactions.md` states under `An unclosed fence ends with its containing block`. - A fence's length is kept as the surplus over the shortest run that can hold its content rather than as the run the file spelled. A fence has to outrun anything inside it, so the length is a floor the content can raise at any time, and recording the number itself made the record shift whenever an edit — or a file whose own parse leaves a fence unclosed, as `corpus/commonmark/code.md` does — forced a wider run than the file was written with. The surplus survives that, because it is measured against the same floor on the way back in. -- A fence's indentation is measured against the document root, where the column the fence opens at is the indentation. Inside a container that column also counts the prefix the container wrote, and mdast names neither separately, so the prefix is taken from the narrowest line the block holds: CommonMark strips up to the fence's own indentation from each content line, which makes that line the prefix alone wherever one line was written without the indentation. A block whose every line keeps some of it reads the indentation as narrower and is written with less of it, which costs bytes rather than content, because CommonMark strips whatever indentation the fence is written with back off on the way in. +- A fence's indentation is kept only at the document root, where the column the fence opens at is the indentation and nothing else. Inside a container that column also counts the prefix the container wrote, and mdast names neither separately. Deriving the prefix from the block's own lines was measured and rejected: it assumes the container writes the same prefix on the line the block opens on as on the lines under it, which a footnote definition does not — it writes its label on the first and indents the rest by four, so the difference read as indentation the file never wrote and was written into the code the block holds. A form axis that can reach the content is narrower than one that cannot, under the ordering in [Preserve the form a file was written in](#preserve-the-form-a-file-was-written-in), so a fence inside a container keeps no indentation of its own. That costs bytes rather than content, and it is the one axis of this class Typora 1.14.9 keeps and Leafdown does not. - 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. diff --git a/src/features/editor/plugins/codeForm.ts b/src/features/editor/plugins/codeForm.ts index 1417e15..c560035 100644 --- a/src/features/editor/plugins/codeForm.ts +++ b/src/features/editor/plugins/codeForm.ts @@ -34,7 +34,7 @@ const markAuthoredForm = (node: MarkdownNode, source: string, atRoot: boolean) = value: (child.value as string | undefined) ?? "", column, atRoot, - endsDocument: atRoot && child === children[children.length - 1], + endsDocument: child === children[children.length - 1], }); const authored = child as Record; diff --git a/src/features/editor/tests/markdownCompatibility.test.ts b/src/features/editor/tests/markdownCompatibility.test.ts index 4d32254..ff45daf 100644 --- a/src/features/editor/tests/markdownCompatibility.test.ts +++ b/src/features/editor/tests/markdownCompatibility.test.ts @@ -1343,19 +1343,38 @@ describe("Code block form", () => { expect(mounted.getMarkdown()).toBe(source); }); - // A fence can be left open only where the block ends the document, so one the file left open - // inside a container is closed on the way out and recorded closed. Recording it open would record - // a form the file can never be written in, and the record would flip on the save that closes it. - it("closes a fence the file left open inside a blockquote", async () => { - const mounted = await mountEditor("> ```\n> code\n\nAfter.\n"); + // An open fence runs to the end of the block that holds it, so one standing last in a container + // is ended by that container rather than by a run of its own and stays open. The blockquote's own + // prefix is what closes it, which is why the paragraph after it is still outside the code. + it.each([ + { name: "a blockquote", source: "> ```\n> code\n\nAfter.\n" }, + { name: "a list item", source: "- ```\n code\n- second\n" }, + { name: "a list item holding a blockquote", source: "- > ```\n > code\n\nAfter.\n" }, + ])("keeps a fence the file left open at the end of $name open", async ({ source }) => { + const mounted = await mountEditor(source); const written = mounted.getMarkdown(); - expect(written).toBe("> ```\n> code\n> ```\n\nAfter.\n"); + expect(written).toBe(source); expect((await mountEditor(written)).view.state.doc.toJSON()).toEqual( mounted.view.state.doc.toJSON(), ); }); + // A footnote definition writes its label on the line the block opens on and indents the lines + // under it by four, so the column the fence opens at says nothing about indentation the file + // wrote. Reading the difference as indentation writes it into the code the block holds. + it("keeps a fence inside a footnote definition clear of the label's own width", async () => { + const mounted = await mountEditor("[^a]: ```\n code\n\nAfter.\n"); + const written = mounted.getMarkdown(); + + expect(written).toBe("[^a]: ```\n code\nAfter.\n"); + expect((await mountEditor(written)).view.state.doc.textContent).toBe( + mounted.view.state.doc.textContent, + ); + expect(mounted.view.state.doc.textContent).toContain("code"); + expect(mounted.view.state.doc.textContent).not.toContain(" code"); + }); + // A fence left open runs to the end of the file, so a block that stops ending the document has to // be closed or it reads the blocks after it as its own content. it("closes an unclosed fence once a block follows it", async () => { diff --git a/src/features/editor/utils/codeMarkdown.ts b/src/features/editor/utils/codeMarkdown.ts index 3c6fb7b..b635cd0 100644 --- a/src/features/editor/utils/codeMarkdown.ts +++ b/src/features/editor/utils/codeMarkdown.ts @@ -30,8 +30,6 @@ export const CODE_SEPARATOR_ATTRIBUTE_NAME = "codeSeparator"; export const CODE_INDENT_ATTRIBUTE_NAME = "codeIndent"; export const CODE_CLOSED_ATTRIBUTE_NAME = "closed"; -const ROOT_MARKDOWN_TYPE = "root"; - export type CodeFence = "`" | "~"; // The form a block is written in when it has none of its own: one the editor created, and one @@ -137,49 +135,16 @@ export const readCodeIndent = (source: object): number => { export const readCodeClosed = (source: object): boolean => (source as Record)[CODE_CLOSED_ATTRIBUTE_NAME] !== false; -// A container writes the same prefix onto every line the block holds, and CommonMark strips up to -// the fence's own indentation from each content line, so the narrowest content line answers for -// that prefix wherever one line was written without the indentation. A block whose every line -// keeps some of it reads the prefix as wider and the indentation as narrower, which writes the -// block back with less indentation than the file gave it rather than with a prefix the container -// never wrote. A blank line spells neither and is passed over. -const findContainerPrefixWidth = (raw: string, value: string) => { - const lines = raw.split("\n"); - const contents = value === "" ? [] : value.split("\n"); - let width: number | undefined; - - for (const [index, content] of contents.entries()) { - const line = lines[index + 1]; - - if (line === undefined || content === "") { - continue; - } - - const measured = line.length - content.length; - - width = width === undefined ? measured : Math.min(width, measured); - } - - return width; -}; - // A fence's slice opens at the fence itself, past whatever indentation the file gave it, so the -// indentation is read off the column instead. At the document root that column is the indentation; -// inside a container it also counts the prefix the container wrote, which only the block's own -// lines separate out. -const findFenceIndent = (raw: string, column: number, atRoot: boolean, value: string) => { - const offset = column - 1; - - if (offset <= 0) { - return DEFAULT_CODE_INDENT; - } - - const prefix = atRoot ? 0 : findContainerPrefixWidth(raw, value); - - return prefix === undefined - ? DEFAULT_CODE_INDENT - : Math.min(Math.max(offset - prefix, 0), CODE_INDENT_MAX); -}; +// indentation is read off the column instead. Only at the document root is that column the +// indentation alone. Inside a container it also counts the prefix the container wrote, and mdast +// names neither separately: the two cannot be told apart from the block's own lines either, +// because a footnote definition writes a label on the line the block opens on and indents the +// lines under it by four, so the difference between them reads as indentation the file never +// wrote and would be written into the content. A block inside a container therefore keeps no +// indentation of its own, which costs bytes rather than content. +const findFenceIndent = (column: number, atRoot: boolean) => + atRoot ? Math.min(Math.max(column - 1, 0), CODE_INDENT_MAX) : DEFAULT_CODE_INDENT; // A fence the file never closed runs to the end of the block, so the slice ends on content rather // than on a run of its own. A run shorter than the one that opened the block closes nothing, which @@ -234,7 +199,7 @@ export const findCodeForm = ({ fence, fenceSurplus: Math.max(run.length - findRequiredFenceLength(value, fence), 0), separator: info === undefined ? DEFAULT_CODE_SEPARATOR : spacing, - indent: findFenceIndent(raw, column, atRoot, value), + indent: findFenceIndent(column, atRoot), closed: !endsDocument || findFenceClosed(raw, fence, run.length), }; }; @@ -242,7 +207,7 @@ export const findCodeForm = ({ // A fence left open runs to the end of the file, so the form is written back only where the block // ends the document. Anything after it would be read as the code the block holds. const standsLastInDocument = (node: CodeNode, parent: StringifyParent) => - parent?.type === ROOT_MARKDOWN_TYPE && parent.children[parent.children.length - 1] === node; + parent !== undefined && parent.children[parent.children.length - 1] === node; // The handler sizes the run to the content it just wrote, and a fence has to outrun anything // inside it, so the file's own length is kept as the surplus over that floor rather than as a