diff --git a/AGENTS.md b/AGENTS.md index 0336235e..f4c80c7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -585,6 +585,62 @@ Example: } ``` +## Indentation Inside Components + +A block component's children may be indented (aligned under the `::` marker) or +not — both are valid input. Two rules keep the two forms interchangeable and +lossless through `parseMarkdown` → `renderMarkdown`: + +**Parse — dedent the children, don't raise the floor.** A child indented *less* +than its own component marker is never dropped. Each child line is shifted left +by `min(markerIndent, its own indentation)` and the region is tokenized against +a zero floor (`comark_block` in `src/plugins/components.ts` via +`src/internal/parse/indent.ts`, mirroring the line-mark mutation `blockquote` +uses to strip its markers). Lines inside a +fenced code block all take the *opening fence's* shift, so the fence and its +body move together. The shift is all or nothing: a tab that would have to be +split into columns puts the region back and keeps the marker floor. A component +with no outdented child is untouched. + +**Stringify — re-indent a block, never a line.** Nested components indent their +rendered output by 2 spaces per level (`indent()` in `src/utils/index.ts`). The +prefix is added to every line of the block, including the body of a fenced code +block, so the fence and its content always move together. + +Uniform shifting is what makes fenced code survive. Indentation inside a fence +is significant whitespace, and the parser cannot tell padding apart from code: + +```md +::tabs + :::tabs-item{label="Code"} +```mdc + ::accordion + :: +``` + ::: +:: +``` + +The fence sits at indent 0 under a marker at indent 2, so fence and body shift +by 0 and the body keeps the two spaces it has relative to its fence +(`" ::accordion\n ::"`). Rendering back aligns the fence with its parent and +carries the body along, which is a fixed point on re-parse: + +```md +::tabs + :::tabs-item{label="Code"} + ```mdc + ::accordion + :: + ``` + ::: +:: +``` + +SPEC coverage: `SPEC/COMARK/component-nested-*-outdented.md`, +`SPEC/COMARK/component-nested-codeblock-indented.md`, +`SPEC/COMARK/codeblock-indented-content.md`. + ## Vue/React/Svelte/Angular Components ### Markdown Component (High-level) diff --git a/packages/comark/SPEC/COMARK/codeblock-indented-content.md b/packages/comark/SPEC/COMARK/codeblock-indented-content.md new file mode 100644 index 00000000..da267f2c --- /dev/null +++ b/packages/comark/SPEC/COMARK/codeblock-indented-content.md @@ -0,0 +1,58 @@ +## Input + +```md +::card +```mdc + ::accordion + :: +``` +:: +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "card", + {}, + [ + "pre", + { + "language": "mdc" + }, + [ + "code", + { + "class": "language-mdc" + }, + " ::accordion\n ::" + ] + ] + ] + ] +} +``` + +## HTML + +```html + +
  ::accordion
+    ::
+
+``` + +## Markdown + +```md +::card +```mdc + ::accordion + :: +``` +:: +``` diff --git a/packages/comark/SPEC/COMARK/component-nested-codeblock-indented.md b/packages/comark/SPEC/COMARK/component-nested-codeblock-indented.md new file mode 100644 index 00000000..3de36f54 --- /dev/null +++ b/packages/comark/SPEC/COMARK/component-nested-codeblock-indented.md @@ -0,0 +1,70 @@ +## Input + +```md +::tabs + :::tabs-item{label="Code"} + ```mdc + ::accordion + :: + ``` + ::: +:: +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "tabs", + {}, + [ + "tabs-item", + { + "label": "Code" + }, + [ + "pre", + { + "language": "mdc" + }, + [ + "code", + { + "class": "language-mdc" + }, + " ::accordion\n ::" + ] + ] + ] + ] + ] +} +``` + +## HTML + +```html + + +
  ::accordion
+      ::
+
+
+``` + +## Markdown + +```md +::tabs + :::tabs-item{label="Code"} + ```mdc + ::accordion + :: + ``` + ::: +:: +``` diff --git a/packages/comark/SPEC/COMARK/component-nested-codeblock-outdented.md b/packages/comark/SPEC/COMARK/component-nested-codeblock-outdented.md new file mode 100644 index 00000000..f827062a --- /dev/null +++ b/packages/comark/SPEC/COMARK/component-nested-codeblock-outdented.md @@ -0,0 +1,70 @@ +## Input + +```md +::tabs + :::tabs-item{label="Code"} +```mdc + ::accordion + :: +``` + ::: +:: +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "tabs", + {}, + [ + "tabs-item", + { + "label": "Code" + }, + [ + "pre", + { + "language": "mdc" + }, + [ + "code", + { + "class": "language-mdc" + }, + " ::accordion\n ::" + ] + ] + ] + ] + ] +} +``` + +## HTML + +```html + + +
  ::accordion
+      ::
+
+
+``` + +## Markdown + +```md +::tabs + :::tabs-item{label="Code"} + ```mdc + ::accordion + :: + ``` + ::: +:: +``` diff --git a/packages/comark/SPEC/COMARK/component-nested-deep-outdented.md b/packages/comark/SPEC/COMARK/component-nested-deep-outdented.md new file mode 100644 index 00000000..3ac8de8f --- /dev/null +++ b/packages/comark/SPEC/COMARK/component-nested-deep-outdented.md @@ -0,0 +1,59 @@ +## Input + +```md +::a-tabs + :::a-item + ::::a-inner +text + :::: + ::: +:: +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "a-tabs", + {}, + [ + "a-item", + {}, + [ + "a-inner", + {}, + "text" + ] + ] + ] + ] +} +``` + +## HTML + +```html + + + + text + + + +``` + +## Markdown + +```md +::a-tabs + :::a-item + ::::a-inner + text + :::: + ::: +:: +``` diff --git a/packages/comark/SPEC/COMARK/component-nested-paragraph-outdented.md b/packages/comark/SPEC/COMARK/component-nested-paragraph-outdented.md new file mode 100644 index 00000000..f5557da9 --- /dev/null +++ b/packages/comark/SPEC/COMARK/component-nested-paragraph-outdented.md @@ -0,0 +1,51 @@ +## Input + +```md +::tabs + :::tabs-item{label="Code"} +hello world + ::: +:: +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "tabs", + {}, + [ + "tabs-item", + { + "label": "Code" + }, + "hello world" + ] + ] + ] +} +``` + +## HTML + +```html + + + hello world + + +``` + +## Markdown + +```md +::tabs + :::tabs-item{label="Code"} + hello world + ::: +:: +``` diff --git a/packages/comark/SPEC/COMARK/component-nested-yaml-props-outdented.md b/packages/comark/SPEC/COMARK/component-nested-yaml-props-outdented.md new file mode 100644 index 00000000..47aebecd --- /dev/null +++ b/packages/comark/SPEC/COMARK/component-nested-yaml-props-outdented.md @@ -0,0 +1,56 @@ +## Input + +```md +::accordion + :::accordion-item +--- +icon: check +label: Question one? +--- +content + ::: +:: +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "accordion", + {}, + [ + "accordion-item", + { + "icon": "check", + "label": "Question one?" + }, + "content" + ] + ] + ] +} +``` + +## HTML + +```html + + + content + + +``` + +## Markdown + +```md +::accordion + :::accordion-item{icon="check" label="Question one?"} + content + ::: +:: +``` diff --git a/packages/comark/src/internal/parse/indent.ts b/packages/comark/src/internal/parse/indent.ts new file mode 100644 index 00000000..a8818ab2 --- /dev/null +++ b/packages/comark/src/internal/parse/indent.ts @@ -0,0 +1,94 @@ +/** + * Indentation shifting for a block component's children. + * + * A child indented less than its own `::` marker used to be dropped. + * These helpers shift the region left instead. + */ + +import type { StateBlock } from 'markdown-exit' + +/** + * Remove up to `columns` columns of indentation from `line`, in place. + * Returns `false` when a tab spans the boundary, leaving the line untouched. + * `bMarks` moves past the consumed characters, so offsets derived from it hold. + */ +function dedentLine(state: StateBlock, line: number, columns: number): boolean { + const lineStart = state.bMarks[line] + const max = state.eMarks[line] + let pos = lineStart + let consumed = 0 + + while (pos < max && consumed < columns) { + const code = state.src.charCodeAt(pos) + if (code === 0x20 /* space */) { + consumed++ + } else if (code === 0x09 /* tab */) { + const width = 4 - ((consumed + state.bsCount[line]) % 4) + // A tab across the boundary would have to become spaces to be split. + if (consumed + width > columns) return false + consumed += width + } else { + // Less indentation than asked for: a fence body left of its own fence. + break + } + pos++ + } + + if (consumed > 0) { + state.bMarks[line] = pos + state.bsCount[line] += consumed + state.sCount[line] -= consumed + state.tShift[line] -= pos - lineStart + } + + return true +} + +/** + * Tokenize `[from, to)` with every line dedented by its entry in `shifts`. + * Returns `false` when a line resists the shift, leaving the region untouched. + * All or nothing: a half-shifted region would parse as neither form. + */ +export function tokenizeDedented(state: StateBlock, from: number, to: number, shifts: number[]): boolean { + const bMarks: number[] = [] + const bsCount: number[] = [] + const sCount: number[] = [] + const tShift: number[] = [] + + for (let line = from; line < to; line++) { + bMarks.push(state.bMarks[line]) + bsCount.push(state.bsCount[line]) + sCount.push(state.sCount[line]) + tShift.push(state.tShift[line]) + if (!dedentLine(state, line, shifts[line - from])) { + restoreLines(state, from, bMarks, bsCount, sCount, tShift) + return false + } + } + + const blkIndent = state.blkIndent + state.blkIndent = 0 + state.md.block.tokenize(state, from, to) + state.blkIndent = blkIndent + restoreLines(state, from, bMarks, bsCount, sCount, tShift) + + return true +} + +/** Put back the line marks saved before {@link dedentLine} shifted them. */ +function restoreLines( + state: StateBlock, + from: number, + bMarks: number[], + bsCount: number[], + sCount: number[], + tShift: number[] +): void { + for (let i = 0; i < bMarks.length; i++) { + const line = from + i + state.bMarks[line] = bMarks[i] + state.bsCount[line] = bsCount[i] + state.sCount[line] = sCount[i] + state.tShift[line] = tShift[i] + } +} diff --git a/packages/comark/src/internal/stringify/handlers/pre.ts b/packages/comark/src/internal/stringify/handlers/pre.ts index a4299693..6b3d5c80 100644 --- a/packages/comark/src/internal/stringify/handlers/pre.ts +++ b/packages/comark/src/internal/stringify/handlers/pre.ts @@ -25,7 +25,7 @@ export function pre(node: ElementNode, state: State) { // Meta always has a leading space const meta = attributes.meta ? ' ' + attributes.meta : '' - const code = String(node[1]?.code || textContent(node)).trim() + const code = String(node[1]?.code || textContent(node)).trimEnd() const fence = pickFence(code) const fenceBlock = fence + language + filename + highlights + meta + '\n' + code + '\n' + fence diff --git a/packages/comark/src/plugins/components.ts b/packages/comark/src/plugins/components.ts index 18725d4d..dc92fd5e 100644 --- a/packages/comark/src/plugins/components.ts +++ b/packages/comark/src/plugins/components.ts @@ -14,6 +14,7 @@ import { defineComarkPlugin } from '../utils/helpers.ts' import { findClosingBracket, parseBracketContent } from '../internal/parse/syntax/brackets.ts' import { parseBlockParams } from '../internal/parse/syntax/block-params.ts' import { parseYaml } from '../internal/yaml.ts' +import { tokenizeDedented } from '../internal/parse/indent.ts' /** * A component name must start with a letter or `$`, followed by word chars, @@ -130,6 +131,11 @@ const markdownItComarkBlock: PluginSimple = (md) => { if (silent) return true + // Columns to strip from each child line, indexed from `startLine + 1`. + const childShifts: number[] = [] + let codeFenceShift = 0 + let hasOutdentedChild = false + nextLine = startLine for (;;) { @@ -141,6 +147,10 @@ const markdownItComarkBlock: PluginSimple = (md) => { if (start < max && state.sCount[nextLine] < state.blkIndent) break + const lineIndent = start >= max ? indent : state.sCount[nextLine] + if (lineIndent < indent) hasOutdentedChild = true + childShifts.push(start >= max ? 0 : inCodeFence ? codeFenceShift : Math.min(indent, lineIndent)) + const lineCharCode = state.src.charCodeAt(start) // Detect closing code fence (``` or ~~~) @@ -164,6 +174,7 @@ const markdownItComarkBlock: PluginSimple = (md) => { inCodeFence = true codeFenceCharCode = lineCharCode codeFenceCount = fencePos - start + codeFenceShift = childShifts[childShifts.length - 1] continue } } @@ -222,12 +233,16 @@ const markdownItComarkBlock: PluginSimple = (md) => { state.push('paragraph_close', 'p', -1) } - const blkIndent = state.blkIndent - state.blkIndent = indent state.env.comarkBlockTokens ||= [] as Token[] state.env.comarkBlockTokens.unshift(tokenOpen) - state.md.block.tokenize(state, startLine + 1, nextLine) - state.blkIndent = blkIndent + + if (!hasOutdentedChild || !tokenizeDedented(state, startLine + 1, nextLine, childShifts)) { + const blkIndent = state.blkIndent + state.blkIndent = indent + state.md.block.tokenize(state, startLine + 1, nextLine) + state.blkIndent = blkIndent + } + state.env.comarkBlockTokens.shift() const tokenClose = state.push('mdc_block_close', params.name, -1) @@ -285,7 +300,7 @@ const markdownItComarkBlock: PluginSimple = (md) => { if (!found) return false if (!silent) { - const yaml = state.src.slice(state.bMarks[startLine + 1], state.eMarks[lineEnd - 1]) + const yaml = state.getLines(startLine + 1, lineEnd, state.blkIndent, false) const data = parseYaml(yaml) const token = state.env.comarkBlockTokens[0] Object.entries(data || {}).forEach(([key, value]) => { diff --git a/test/bundle.test.ts b/test/bundle.test.ts index c0dc230e..f2bc25a8 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -67,7 +67,7 @@ describe('package bundle size', { timeout: 60_000 }, () => { "@comark/react": "36.9k (74 files)", "@comark/svelte": "43.9k (82 files)", "@comark/vue": "51.7k (78 files)", - "comark": "363k (156 files)", + "comark": "366k (158 files)", } `) })