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)",
}
`)
})