Skip to content

Commit c9acb33

Browse files
committed
Add: support and tests for nested containers
Fixes #7
1 parent 7653294 commit c9acb33

File tree

2 files changed

+231
-40
lines changed

2 files changed

+231
-40
lines changed

src/index.ts

Lines changed: 50 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,14 @@ export const plugin: Plugin<[FlexibleContainerOptions?], Root> = (options) => {
331331

332332
// remove the newline "\n" in the beginning, and get the rest of the value
333333
value = value.slice(1);
334+
335+
// If the very next line starts with another fence (e.g., "::: tip" or just ":::")
336+
// this is an invalid/misplaced pattern. Keep paragraph as-is (regular), do not mutate.
337+
const nextLineFenceWithType = new RegExp(`^${fence}\\s*[\\w-]+`, "u");
338+
const nextLineFenceOnly = new RegExp(`^${fence}\\s*$`, "u");
339+
if (nextLineFenceWithType.test(value) || nextLineFenceOnly.test(value)) {
340+
return { flag: "regular", type, rawtitle: title };
341+
}
334342
} else {
335343
// means that there is a "type" and/or a "title"
336344

@@ -348,16 +356,22 @@ export const plugin: Plugin<[FlexibleContainerOptions?], Root> = (options) => {
348356
if (value.endsWith(":".repeat(fenceLen))) {
349357
// means that the container starts and ends within same paragraph's Text child
350358

351-
// remove the "\n:::" at the end
352-
value = value.slice(0, -fenceLen).trim();
359+
// remove the closing fence and trim
360+
const afterClose = value.slice(0, -fenceLen).trim();
353361

354-
flag = "complete";
362+
// If there is no type and no title and no content, do not treat as container
363+
if (!type && !title && afterClose === "") {
364+
flag = "regular";
365+
} else {
366+
textElement.value = afterClose;
367+
flag = "complete";
368+
}
355369
} else {
370+
// Not closed in the same paragraph. Treat as start (mutated)
356371
flag = "mutated";
372+
// mutate the current node to remove the opening fence line
373+
textElement.value = value;
357374
}
358-
359-
// mutate the current node
360-
textElement.value = value;
361375
}
362376

363377
return { flag, type, rawtitle: title };
@@ -523,47 +537,41 @@ export const plugin: Plugin<[FlexibleContainerOptions?], Root> = (options) => {
523537
}
524538

525539
const transformer: Transformer<Root> = (tree) => {
526-
// Pre-pass: split paragraphs that contain multiple fence lines into separate paragraphs.
527-
visit(tree, "paragraph", function (node, index, parent) {
540+
// Do not pre-split paragraphs; handle opening/closing within the analyzer
541+
// to preserve expected behavior for invalid patterns.
542+
543+
// Targeted split: if a paragraph starts with a fence and contains a pure closing
544+
// fence line later followed by more content, split into two paragraphs at that line.
545+
visit(tree, "paragraph", function (node, index, parent) {
528546
/* v8 ignore next */
529-
if (!parent || typeof index === "undefined") return CONTINUE;
530-
if (node.children.length !== 1 || node.children[0].type !== "text") return CONTINUE;
547+
if (!parent || typeof index === "undefined") return;
548+
if (node.children.length !== 1 || node.children[0].type !== "text") return;
531549

532550
const text = (node.children[0] as Text).value;
533-
if (!text.includes(":")) return CONTINUE;
534-
535-
// If there's no newline or no fence-like lines, skip.
536-
if (!/\n/.test(text)) return CONTINUE;
551+
const open = text.match(/^(:{3,})/u);
552+
if (!open) return;
553+
if (!text.includes("\n")) return;
537554

555+
const fence = open[1];
538556
const lines = text.split(/\n/);
539-
let buffer: string[] = [];
540-
const out: Paragraph[] = [];
541-
542-
const flushBuffer = () => {
543-
if (buffer.length) {
544-
out.push(u("paragraph", [u("text", buffer.join("\n"))]) as Paragraph);
545-
buffer = [];
546-
}
547-
};
548-
549-
for (const line of lines) {
550-
if (/^\s*:{3,}/u.test(line)) {
551-
// Fence-like line becomes its own paragraph
552-
flushBuffer();
553-
out.push(u("paragraph", [u("text", line)]) as Paragraph);
554-
} else {
555-
buffer.push(line);
557+
// Find a line that equals the same-length fence (allow surrounding spaces) and has content after it
558+
let closeIdx = -1;
559+
for (let i = 1; i < lines.length; i++) {
560+
if (new RegExp(`^\\s*${fence}\\s*$`, "u").test(lines[i])) {
561+
closeIdx = i;
562+
break;
556563
}
557564
}
565+
if (closeIdx === -1) return;
566+
if (closeIdx >= lines.length - 1) return; // nothing after closing -> don't split
558567

559-
flushBuffer();
568+
const firstPart = lines.slice(0, closeIdx + 1).join("\n");
569+
const secondPart = lines.slice(closeIdx + 1).join("\n");
560570

561-
if (out.length > 1) {
562-
parent.children.splice(index, 1, ...out);
563-
return index + out.length; // continue after inserted nodes
564-
}
565-
566-
return CONTINUE;
571+
const p1 = u("paragraph", [u("text", firstPart)]) as Paragraph;
572+
const p2 = u("paragraph", [u("text", secondPart)]) as Paragraph;
573+
parent.children.splice(index, 1, p1, p2);
574+
return index + 2;
567575
});
568576

569577
// if a html node.value ends with a closing fence, remove and carry it into a new paragraph
@@ -662,7 +670,10 @@ export const plugin: Plugin<[FlexibleContainerOptions?], Root> = (options) => {
662670
containerChildren.push(closingNode);
663671
}
664672

665-
// Allow empty containers as well
673+
// If there is no type, no title and no content between fences, treat as regular text
674+
if (!type && !title && containerChildren.length === 0) {
675+
return CONTINUE;
676+
}
666677

667678
const titleNode = constructTitle(type, title, titleProps);
668679

tests/nesting.spec.ts

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,200 @@ import { describe, it, expect } from "vitest";
22
import dedent from "dedent";
33

44
import { process } from "./util/index";
5+
import { type FlexibleContainerOptions } from "../src";
56

67
describe("nesting with varying fence lengths", () => {
78
it("supports an outer 4-colon container with inner 3-colon containers", async () => {
89
const input = dedent`
910
:::: tab-group My Group
11+
1012
::: tab Some Tab
1113
:::
14+
1215
::: tab Another tab
1316
:::
17+
18+
::::
19+
`;
20+
21+
expect(await process(input)).toMatchInlineSnapshot(
22+
`"<div class="remark-container tab-group"><div class="remark-container-title tab-group">My Group</div><div class="remark-container tab"><div class="remark-container-title tab">Some Tab</div></div><div class="remark-container tab"><div class="remark-container-title tab">Another tab</div></div></div>"`,
23+
);
24+
});
25+
26+
it("supports custom component names (TabGroup/CodeTab) and custom title components", async () => {
27+
const options: FlexibleContainerOptions = {
28+
containerTagName(type) {
29+
if (type === "tab-group") return "TabGroup";
30+
if (type === "tab") return "CodeTab";
31+
return "div";
32+
},
33+
containerClassName(type) {
34+
if (type === "tab-group") return ["tab-group"];
35+
if (type === "tab") return ["tab"];
36+
return ["remark-container", type ?? ""];
37+
},
38+
titleTagName(type) {
39+
if (type === "tab-group") return "TabGroupTitle";
40+
if (type === "tab") return "CodeTabTitle";
41+
return "div";
42+
},
43+
titleClassName(type) {
44+
if (type === "tab-group") return ["tab-group-title"];
45+
if (type === "tab") return ["tab-title"];
46+
return ["remark-container-title"];
47+
},
48+
};
49+
50+
const input = dedent`
51+
:::: tab-group My Group
52+
53+
::: tab Some Tab
54+
:::
55+
56+
::: tab Another tab
57+
:::
58+
59+
::::
60+
`;
61+
62+
expect(await process(input, options)).toMatchInlineSnapshot(
63+
`"<TabGroup class="tab-group"><TabGroupTitle class="tab-group-title">My Group</TabGroupTitle><CodeTab class="tab"><CodeTabTitle class="tab-title">Some Tab</CodeTabTitle></CodeTab><CodeTab class="tab"><CodeTabTitle class="tab-title">Another tab</CodeTabTitle></CodeTab></TabGroup>"`,
64+
);
65+
});
66+
67+
it("supports custom tag names (tab-group/code-tab) and custom title tags", async () => {
68+
const options: FlexibleContainerOptions = {
69+
containerTagName(type) {
70+
if (type === "tab-group") return "tab-group";
71+
if (type === "tab") return "code-tab";
72+
return "div";
73+
},
74+
containerClassName(type) {
75+
if (type === "tab-group") return ["tabs"];
76+
if (type === "tab") return ["tab"];
77+
return ["remark-container", type ?? ""];
78+
},
79+
titleTagName(type) {
80+
if (type === "tab-group") return "tab-group-title";
81+
if (type === "tab") return "code-tab-title";
82+
return "div";
83+
},
84+
titleClassName(type) {
85+
if (type === "tab-group") return ["tabs-title"];
86+
if (type === "tab") return ["tab-title"];
87+
return ["remark-container-title"];
88+
},
89+
};
90+
91+
const input = dedent`
92+
:::: tab-group My Group
93+
94+
::: tab First
95+
:::
96+
97+
::: tab Second
98+
:::
99+
100+
::::
101+
`;
102+
103+
expect(await process(input, options)).toMatchInlineSnapshot(
104+
`"<tab-group class="tabs"><tab-group-title class="tabs-title">My Group</tab-group-title><code-tab class="tab"><code-tab-title class="tab-title">First</code-tab-title></code-tab><code-tab class="tab"><code-tab-title class="tab-title">Second</code-tab-title></code-tab></tab-group>"`,
105+
);
106+
});
107+
108+
it("supports passing custom properties to containers and titles", async () => {
109+
const options: FlexibleContainerOptions = {
110+
containerTagName(type) {
111+
if (type === "tab-group") return "tab-group";
112+
if (type === "tab") return "code-tab";
113+
return "div";
114+
},
115+
containerClassName(type) {
116+
if (type === "tab-group") return ["tabs"];
117+
if (type === "tab") return ["tab"];
118+
return ["remark-container", type ?? ""];
119+
},
120+
containerProperties(type, title) {
121+
if (type === "tab-group") return { role: "tablist", "data-kind": "group" } as const;
122+
if (type === "tab") return { role: "tab", "data-tab": title } as const;
123+
return {} as const;
124+
},
125+
titleTagName(type) {
126+
if (type === "tab-group") return "tab-group-title";
127+
if (type === "tab") return "code-tab-title";
128+
return "div";
129+
},
130+
titleClassName(type) {
131+
if (type === "tab-group") return ["tabs-title"];
132+
if (type === "tab") return ["tab-title"];
133+
return ["remark-container-title"];
134+
},
135+
titleProperties(type, title) {
136+
if (type === "tab-group") return { "data-title": title } as const;
137+
if (type === "tab") return { "aria-selected": title === "Active" } as const;
138+
return {} as const;
139+
},
140+
};
141+
142+
const input = dedent`
143+
:::: tab-group My Group
144+
145+
::: tab Active
146+
:::
147+
148+
::: tab Passive
149+
:::
150+
151+
::::
152+
`;
153+
154+
expect(await process(input, options)).toMatchInlineSnapshot(
155+
`"<tab-group class="tabs" role="tablist" data-kind="group"><tab-group-title class="tabs-title" data-title="My Group">My Group</tab-group-title><code-tab class="tab" role="tab" data-tab="Active"><code-tab-title class="tab-title" aria-selected>Active</code-tab-title></code-tab><code-tab class="tab" role="tab" data-tab="Passive"><code-tab-title class="tab-title">Passive</code-tab-title></code-tab></tab-group>"`,
156+
);
157+
});
158+
159+
it("supports inline specific identifiers for tags/ids/classes for nested containers", async () => {
160+
const input = dedent`
161+
:::: tab-group {tab-group#group.tabs} My Group {tab-group-title#group-title.title}
162+
163+
::: tab {code-tab#t1.pill} Active {code-tab-title#t1-title.pill-title}
164+
:::
165+
166+
::: tab {code-tab#t2.pill} Passive {code-tab-title#t2-title.pill-title}
167+
:::
168+
169+
::::
170+
`;
171+
172+
expect(await process(input)).toMatchInlineSnapshot(
173+
`"<tab-group class="remark-container tab-group tabs" id="group"><tab-group-title class="remark-container-title tab-group title" id="group-title">My Group</tab-group-title><code-tab class="remark-container tab pill" id="t1"><code-tab-title class="remark-container-title tab pill-title" id="t1-title">Active</code-tab-title></code-tab><code-tab class="remark-container tab pill" id="t2"><code-tab-title class="remark-container-title tab pill-title" id="t2-title">Passive</code-tab-title></code-tab></tab-group>"`,
174+
);
175+
});
176+
});
177+
178+
// add a test for this with tags specified, e.g.
179+
// ### ::: [type] [{tagname#id.classname}] [title] [{tagname#id.classname}]
180+
181+
// so it's "<tab-group> and <single-tab>"
182+
183+
describe("nesting with PascalCase brace syntax", () => {
184+
it("supports inline specific identifiers with PascalCase tag names (TabGroup/CodeTab)", async () => {
185+
const input = dedent`
186+
:::: tab-group {TabGroup#group.tabs} My Group {TabGroupTitle#group-title.title}
187+
188+
::: tab {CodeTab#t1.pill} Active {CodeTabTitle#t1-title.pill-title}
189+
:::
190+
191+
::: tab {CodeTab#t2.pill} Passive {CodeTabTitle#t2-title.pill-title}
192+
:::
193+
14194
::::
15195
`;
16196

17197
expect(await process(input)).toMatchInlineSnapshot(
18-
`"<div class=\"remark-container tab-group\"><div class=\"remark-container-title tab-group\">My Group</div><div class=\"remark-container tab\"><div class=\"remark-container-title tab\">Some Tab</div></div><div class=\"remark-container tab\"><div class=\"remark-container-title tab\">Another tab</div></div></div>"`,
198+
`"<TabGroup class="remark-container tab-group tabs" id="group"><TabGroupTitle class="remark-container-title tab-group title" id="group-title">My Group</TabGroupTitle><CodeTab class="remark-container tab pill" id="t1"><CodeTabTitle class="remark-container-title tab pill-title" id="t1-title">Active</CodeTabTitle></CodeTab><CodeTab class="remark-container tab pill" id="t2"><CodeTabTitle class="remark-container-title tab pill-title" id="t2-title">Passive</CodeTabTitle></CodeTab></TabGroup>"`,
19199
);
20200
});
21201
});

0 commit comments

Comments
 (0)