@dogsbay/format-dogsbay-md 0.2.0-beta.126 → 0.2.0-beta.128

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * (js-yaml only) rather than gray-matter, whose entry `require('fs')`s at
12
12
  * module load and would drag `node:fs` back into this graph.
13
13
  */
14
- export { dogsbayMdToTree, parseInline, type ParseResult, type ParseOptions } from "./parse.js";
14
+ export { dogsbayMdToTree, parseInline, containerOpenBefore, type ParseResult, type ParseOptions } from "./parse.js";
15
15
  export { KNOWN_INLINE_DIRECTIVES, type ParseDiagnostic } from "./diagnostics.js";
16
16
  export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
17
17
  export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
package/dist/browser.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * (js-yaml only) rather than gray-matter, whose entry `require('fs')`s at
12
12
  * module load and would drag `node:fs` back into this graph.
13
13
  */
14
- export { dogsbayMdToTree, parseInline } from "./parse.js";
14
+ export { dogsbayMdToTree, parseInline, containerOpenBefore } from "./parse.js";
15
15
  export { KNOWN_INLINE_DIRECTIVES } from "./diagnostics.js";
16
16
  // The `:::name` blocks the parser knows (an editor completes names from it)
17
17
  export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
@@ -30,7 +30,7 @@ export const DOGSBAY_MD_CAPABILITIES = [
30
30
  rendersAs: "a ```mermaid fence with the diagram source",
31
31
  note: "The source round-trips (it re-parses as a code fence, which the build turns back into a diagram); a pre-rendered SVG is not kept.",
32
32
  },
33
- { feature: "youtube", support: "full", rendersAs: "`:::youtube{#id title=\"…\"}`" },
33
+ { feature: "youtube", support: "full", rendersAs: "`::youtube{#id title=\"…\"}`" },
34
34
  // ── Components ───────────────────────────────────────────
35
35
  { feature: "callout", support: "full", rendersAs: "GitHub alert `> [!NOTE]`; `:::type[Title]{…}` for a custom title or a non-GitHub type" },
36
36
  { feature: "details", support: "full", rendersAs: "`:::details{title=\"…\"}`" },
package/dist/cli.js CHANGED
@@ -131,11 +131,13 @@ async function importDogsbayMd(source, opts) {
131
131
  // (mermaid-cli) isn't installed the code block is left as-is and
132
132
  // a one-line warning is printed. See plans/mermaid-dogsbay-md.md.
133
133
  const diagramsOn = opts.diagrams !== false;
134
+ // `content.diagrams: pele` draws them with Pele; `true` (the default) with mmdc
135
+ const diagramEngine = opts.diagrams === "pele" ? "pele" : "mermaid-cli";
134
136
  for (const { filePath, slug, sourcePath } of files) {
135
137
  const content = readFileSync(filePath, "utf-8");
136
138
  const { tree, frontmatter } = dogsbayMdToTree(content);
137
139
  if (diagramsOn) {
138
- await renderDiagrams(tree);
140
+ await renderDiagrams(tree, { engine: diagramEngine });
139
141
  }
140
142
  const title = String(frontmatter.title
141
143
  ?? prettifyName(basename(slug)));
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The `:::name` container block rule: markdown-it-container 4.0.0 (MIT,
3
- * Vitaly Puzrin, Alex Kocharin), adapted. Two changes, both in how the
3
+ * Vitaly Puzrin, Alex Kocharin), adapted. Three changes, all in how the
4
4
  * closing line is found:
5
5
  *
6
6
  * 1. A closer may carry an HTML comment, `::: <!-- end note -->`, the way an
@@ -13,8 +13,10 @@
13
13
  * one code block, with no message. Which lines are code comes from one
14
14
  * block-level pass over the source with containers off (codeLines).
15
15
  *
16
- * Everything else (the colon rule, indentation, auto-close at the end of the
17
- * parent) is markdown-it-container's.
16
+ * 3. Blocks nest by a stack, not by colon count (NESTING below).
17
+ *
18
+ * Everything else (indentation, auto-close at the end of the parent) is
19
+ * markdown-it-container's.
18
20
  *
19
21
  * markdown-it-container's licence:
20
22
  *
@@ -42,8 +44,40 @@
42
44
  * OTHER DEALINGS IN THE SOFTWARE.
43
45
  */
44
46
  import type MarkdownIt from "markdown-it";
47
+ /**
48
+ * NESTING. A closer with N colons closes the innermost open block opened with exactly N colons,
49
+ * else the innermost opened with fewer, and any block still open inside it. So every block can close with plain `:::`
50
+ * (`:::steps` … `:::tabs` … `:::` … `:::`), and today's scaled nesting (`::::steps` … `:::tabs`
51
+ * … `:::` … `::::`) and the inverse (inner blocks with more colons) read the same. Pandoc's fenced
52
+ * divs nest the same way. Before the stack, a closer ended the OUTERMOST block it was long
53
+ * enough for, so a nested block needed more colons on the outer one, and one `:::` could close
54
+ * several blocks (that case now leaves the outer one open, and the diagnostic says why). "Exactly
55
+ * N first" keeps the old reading where the author scaled: `::::note` … `:::tip` … `::::` closes the
56
+ * note (and the tip with it) as before, rather than the tip.
57
+ *
58
+ * Only a known directive name opens a block in the scan. An unknown `:::word` is a block only if
59
+ * it has a closer of its own and is otherwise text (markUnclosedGenericContainers), and the scan
60
+ * cannot know which, so it does not push one: an unknown block nested at the same colon count
61
+ * as its parent still needs more colons on the parent. `:::` always opens a block that needs a
62
+ * closer; a directive with no body is a leaf, `::youtube{#id}` (plugin-block-leaf.ts), so no
63
+ * opener depends on the line after it.
64
+ *
65
+ * VERIFY. The scan reads raw lines, and markdown-it may not read an opener there as one: in raw
66
+ * HTML, a `$$` math block, a fence that never closes. Where that is depends on where the blocks
67
+ * around it end, so no pass over the page can say in advance. So after the body is tokenized, every
68
+ * opener the scan pushed must have opened a block in it; any that did not are left out and the
69
+ * body is scanned and tokenized again. Counted wrongly, an opener leaves the outer block open to
70
+ * the end of the page (the rule before the stack lost one closer at most). What a block learns is
71
+ * kept for the parse, by its opening line: when an outer block retries, the blocks inside it are
72
+ * tokenized again and start from what they learned, rather than repeating their own retries (which
73
+ * doubled the work at every level). A learned line can be stale in the new context, so the check
74
+ * runs both ways: a skipped line that markdown-it did open as a block is pushed again. Retried
75
+ * lines come out of one budget per parse (a few times the page's lines); past it, the scan stands.
76
+ */
45
77
  export interface ContainerOptions {
46
78
  validate: (params: string, markup: string) => boolean;
47
79
  render?: (tokens: unknown[], idx: number) => string;
48
80
  }
81
+ /** Make `name` an opener for the nesting scan (the known directives; not the generic container). */
82
+ export declare function registerOpener(name: string): void;
49
83
  export declare function containerRule(md: MarkdownIt, name: string, options: ContainerOptions): void;
@@ -7,6 +7,9 @@ const CLOSER_TAIL = /^(?:[ \t]*|[ \t]*(<!--(?:(?!-->)[\s\S])*-->)[ \t]*)$/;
7
7
  /** state.env keys: the pre-pass that finds the code, and its result. */
8
8
  const NO_CONTAINERS = "__dogsbayNoContainers";
9
9
  const CODE_LINES = "__dogsbayCodeLines";
10
+ /** state.env keys for VERIFY: the parse's retry budget, and each block's learned openers to skip. */
11
+ const RETRY_BUDGET = "__dogsbayContainerRetries";
12
+ const LEARNED = "__dogsbayContainerNotOpeners";
10
13
  /**
11
14
  * The source lines inside a fenced code block, from one block-level pass over the whole source
12
15
  * with containers switched off, made the first time a container needs it and kept in the parse's
@@ -49,11 +52,48 @@ function closesFence(line, markup, after) {
49
52
  const m = /^[ \t>]*(`{3,}|~{3,})[ \t]*$/.exec(line);
50
53
  return m !== null && m[1][0] === markup[0] && m[1].length >= markup.length;
51
54
  }
55
+ /** The directive names the scan treats as openers (plugin-containers registers them). */
56
+ const KNOWN_OPENERS = new Set();
57
+ /** Whether the text after an opener's colons starts with a known directive name. */
58
+ function isKnownOpener(rest) {
59
+ const m = /^\s*([A-Za-z][\w-]*)(?=$|[\s{[])/.exec(rest);
60
+ return m !== null && KNOWN_OPENERS.has(m[1]);
61
+ }
62
+ /** Make `name` an opener for the nesting scan (the known directives; not the generic container). */
63
+ export function registerOpener(name) {
64
+ KNOWN_OPENERS.add(name);
65
+ }
66
+ /** Tokens whose lines are not block openers: an opener the scan pushed inside one is wrong (VERIFY). */
67
+ const READ_AS_OTHER = new Set(["paragraph_open", "heading_open", "html_block", "fence", "code_block", "math_block", "hr", "table_open"]);
68
+ /** The openers each block (by opening line) learned to skip in this parse (VERIFY). */
69
+ function learned(state) {
70
+ const env = state.env;
71
+ if (!env)
72
+ return new Map();
73
+ const kept = env[LEARNED];
74
+ if (kept && kept.src === state.src)
75
+ return kept.map;
76
+ const map = new Map();
77
+ env[LEARNED] = { src: state.src, map };
78
+ return map;
79
+ }
80
+ /** The lines left in this parse's retry budget (VERIFY), shared by every block in it. */
81
+ function retryBudget(state) {
82
+ const env = state.env;
83
+ const fresh = { lines: 4 * state.bMarks.length + 1000 };
84
+ if (!env)
85
+ return fresh;
86
+ const kept = env[RETRY_BUDGET];
87
+ if (kept && kept.src === state.src)
88
+ return kept.budget;
89
+ env[RETRY_BUDGET] = { src: state.src, budget: fresh };
90
+ return fresh;
91
+ }
52
92
  export function containerRule(md, name, options) {
53
93
  const MARKER = ":".charCodeAt(0);
54
94
  function container(state, startLine, endLine, silent) {
55
- let start = state.bMarks[startLine] + state.tShift[startLine];
56
- let max = state.eMarks[startLine];
95
+ const start = state.bMarks[startLine] + state.tShift[startLine];
96
+ const max = state.eMarks[startLine];
57
97
  if (state.src.charCodeAt(start) !== MARKER)
58
98
  return false;
59
99
  if (state.env?.[NO_CONTAINERS])
@@ -73,64 +113,165 @@ export function containerRule(md, name, options) {
73
113
  const oldParent = state.parentType;
74
114
  const oldLineMax = state.lineMax;
75
115
  state.parentType = "container";
116
+ const tokensBefore = state.tokens.length;
117
+ // Openers markdown-it did not open, found by an earlier try (VERIFY above)
118
+ const known = learned(state);
119
+ const notOpeners = new Set(known.get(startLine));
120
+ let scan;
121
+ for (;;) {
122
+ scan = scanBody(state, startLine, endLine, markerCount, notOpeners);
123
+ // lazy continuations never run past the closer
124
+ state.lineMax = scan.nextLine;
125
+ const open = state.push(`container_${name}_open`, "div", 1);
126
+ open.markup = markup;
127
+ open.block = true;
128
+ open.info = params;
129
+ open.map = [startLine, scan.nextLine];
130
+ if (!scan.autoClosed && (scan.skippedCloser !== undefined || scan.legacyCloser !== undefined)) {
131
+ open.meta = {
132
+ ...(open.meta ?? {}),
133
+ ...(scan.skippedCloser !== undefined ? { skippedCloser: scan.skippedCloser } : {}),
134
+ ...(scan.legacyCloser !== undefined ? { legacyCloser: scan.legacyCloser } : {}),
135
+ };
136
+ }
137
+ state.md.block.tokenize(state, startLine + 1, scan.nextLine);
138
+ const close = state.push(`container_${name}_close`, "div", -1);
139
+ close.markup = scan.closeMarkup;
140
+ close.block = true;
141
+ close.info = scan.closeComment;
142
+ // Wrong: a pushed opener's line that markdown-it read as something else. A line it read as
143
+ // nothing (past markdown-it's nesting limit, where it drops the rest) is not evidence.
144
+ // Missed: a skipped line markdown-it did open as a block (a learned line, stale here).
145
+ const pushed = new Set(scan.pushed);
146
+ const wrong = [];
147
+ const missed = [];
148
+ if (pushed.size > 0 || notOpeners.size > 0) {
149
+ for (let i = tokensBefore + 1; i < state.tokens.length; i++) {
150
+ const t = state.tokens[i];
151
+ if (!t.map)
152
+ continue;
153
+ if (READ_AS_OTHER.has(t.type)) {
154
+ for (let l = t.map[0]; l < t.map[1]; l++)
155
+ if (pushed.delete(l))
156
+ wrong.push(l);
157
+ }
158
+ else if (t.nesting === 1 && t.type.startsWith("container_") && notOpeners.has(t.map[0]))
159
+ missed.push(t.map[0]);
160
+ }
161
+ }
162
+ if (wrong.length === 0 && missed.length === 0)
163
+ break;
164
+ const budget = retryBudget(state);
165
+ if (budget.lines < scan.nextLine - startLine) {
166
+ // Out of budget: this reading stands, with an opener known to be wrong; say so (diagnostics)
167
+ open.meta = { ...(open.meta ?? {}), unverified: true };
168
+ break;
169
+ }
170
+ budget.lines -= scan.nextLine - startLine;
171
+ for (const l of wrong)
172
+ notOpeners.add(l);
173
+ for (const l of missed)
174
+ notOpeners.delete(l);
175
+ state.tokens.length = tokensBefore;
176
+ }
177
+ if (notOpeners.size > 0)
178
+ known.set(startLine, new Set(notOpeners));
179
+ else
180
+ known.delete(startLine);
181
+ state.parentType = oldParent;
182
+ state.lineMax = oldLineMax;
183
+ state.line = scan.nextLine + (scan.autoClosed ? 1 : 0);
184
+ return true;
185
+ }
186
+ /** Find where the block opened at `startLine` ends (NESTING above), skipping the `notOpeners` lines as openers. */
187
+ function scanBody(state, startLine, endLine, markerCount, notOpeners) {
76
188
  let code;
77
189
  let nextLine = startLine;
78
- let autoClosed = false;
79
- let closeMarkup = "";
80
- let closeComment = "";
81
- let skippedCloser;
190
+ const scan = { nextLine, autoClosed: false, closeMarkup: "", closeComment: "", pushed: [] };
191
+ // Blocks opened inside this one and not yet closed, innermost last (see NESTING above)
192
+ // `least` is the fewest colons from the outermost to this one, and `openCount` counts the open
193
+ // blocks by colons, so a closer that fits none is turned away without walking the stack
194
+ const inner = [];
195
+ const openCount = new Map();
196
+ const truncate = (k) => {
197
+ for (let j = k; j < inner.length; j++)
198
+ openCount.set(inner[j].colons, openCount.get(inner[j].colons) - 1);
199
+ inner.length = k;
200
+ };
82
201
  for (;;) {
83
202
  nextLine++;
84
203
  // unclosed: closed by the end of the document or of the parent block
85
204
  if (nextLine >= endLine)
86
205
  break;
87
- start = state.bMarks[nextLine] + state.tShift[nextLine];
88
- max = state.eMarks[nextLine];
206
+ const start = state.bMarks[nextLine] + state.tShift[nextLine];
207
+ const max = state.eMarks[nextLine];
208
+ if (start >= max)
209
+ continue;
89
210
  // a non-empty line indented less than the block ends it (a list item's container)
90
- if (start < max && state.sCount[nextLine] < state.blkIndent)
211
+ if (state.sCount[nextLine] < state.blkIndent)
91
212
  break;
213
+ // a block opened deeper (in a list item) ended at a line indented less than its opener
214
+ while (inner.length > 0 && state.sCount[nextLine] < inner[inner.length - 1].indent)
215
+ truncate(inner.length - 1);
92
216
  if (state.src.charCodeAt(start) !== MARKER)
93
217
  continue;
94
- // a closer is indented less than 4 spaces
218
+ // openers and closers are indented less than 4 spaces past the block
95
219
  if (state.sCount[nextLine] - state.blkIndent >= 4)
96
220
  continue;
97
- pos = start + 1;
221
+ let pos = start + 1;
98
222
  while (pos < max && state.src.charCodeAt(pos) === MARKER)
99
223
  pos++;
100
- // a closer has at least as many colons as the opener
101
- if (pos - start < markerCount)
224
+ const colons = pos - start;
225
+ if (colons < 3)
102
226
  continue;
103
- const tail = CLOSER_TAIL.exec(state.src.slice(pos, max));
104
- if (!tail)
227
+ const rest = state.src.slice(pos, max);
228
+ const tail = CLOSER_TAIL.exec(rest);
229
+ const opener = !tail && isKnownOpener(rest);
230
+ if (!tail && !opener)
105
231
  continue;
106
232
  // a `:::` in a code example is code
107
233
  if ((code ??= codeLines(state)).has(nextLine)) {
108
- skippedCloser ??= nextLine;
234
+ if (tail && colons >= markerCount)
235
+ scan.skippedCloser ??= nextLine;
236
+ continue;
237
+ }
238
+ if (opener) {
239
+ if (notOpeners.has(nextLine))
240
+ continue;
241
+ const least = Math.min(colons, inner.length > 0 ? inner[inner.length - 1].least : colons);
242
+ inner.push({ colons, indent: state.sCount[nextLine], least });
243
+ openCount.set(colons, (openCount.get(colons) ?? 0) + 1);
244
+ scan.pushed.push(nextLine);
109
245
  continue;
110
246
  }
111
- autoClosed = true;
112
- closeMarkup = state.src.slice(start, pos);
113
- closeComment = tail[1] ?? "";
247
+ // Where the rule before the stack (the first closer with enough colons) would have ended this block
248
+ if (colons >= markerCount)
249
+ scan.legacyCloser ??= nextLine;
250
+ // The innermost open block opened with exactly this many colons, else the innermost opened
251
+ // with fewer; it closes, and any block still open inside it. Index -1 is this block.
252
+ const closes = (fits) => {
253
+ for (let k = inner.length - 1; k >= 0; k--)
254
+ if (fits(inner[k].colons))
255
+ return k;
256
+ return fits(markerCount) ? -1 : undefined;
257
+ };
258
+ const exact = (openCount.get(colons) ?? 0) > 0 || markerCount === colons;
259
+ const fewer = markerCount < colons || (inner.length > 0 && inner[inner.length - 1].least < colons);
260
+ const k = exact ? closes((n) => n === colons) : fewer ? closes((n) => n < colons) : undefined;
261
+ // shorter than every open block: it closes nothing here
262
+ if (k === undefined)
263
+ continue;
264
+ if (k >= 0) {
265
+ truncate(k);
266
+ continue;
267
+ }
268
+ scan.autoClosed = true;
269
+ scan.closeMarkup = state.src.slice(start, pos);
270
+ scan.closeComment = tail?.[1] ?? "";
114
271
  break;
115
272
  }
116
- // lazy continuations never run past the closer
117
- state.lineMax = nextLine;
118
- const open = state.push(`container_${name}_open`, "div", 1);
119
- open.markup = markup;
120
- open.block = true;
121
- open.info = params;
122
- open.map = [startLine, nextLine];
123
- if (skippedCloser !== undefined && !autoClosed)
124
- open.meta = { ...(open.meta ?? {}), skippedCloser };
125
- state.md.block.tokenize(state, startLine + 1, nextLine);
126
- const close = state.push(`container_${name}_close`, "div", -1);
127
- close.markup = closeMarkup;
128
- close.block = true;
129
- close.info = closeComment;
130
- state.parentType = oldParent;
131
- state.lineMax = oldLineMax;
132
- state.line = nextLine + (autoClosed ? 1 : 0);
133
- return true;
273
+ scan.nextLine = nextLine;
274
+ return scan;
134
275
  }
135
276
  md.block.ruler.before("fence", `container_${name}`, container, { alt: ["paragraph", "reference", "blockquote", "list"] });
136
277
  const render = options.render ?? (() => "");
@@ -2,7 +2,7 @@ export interface ParseDiagnostic {
2
2
  /** 1-based line in the source (front matter counted). */
3
3
  line: number;
4
4
  /** Stable kind, for filtering. */
5
- code: "unclosed-container" | "stray-closer" | "unknown-container" | "unknown-inline" | "block-directive-inline" | "bad-attributes" | "closer-mismatch" | "dropped-attributes" | "missing-label";
5
+ code: "unclosed-container" | "stray-closer" | "unknown-container" | "unknown-inline" | "block-directive-inline" | "bad-attributes" | "closer-mismatch" | "dropped-attributes" | "missing-label" | "leaf-as-block" | "nesting-unchecked";
6
6
  message: string;
7
7
  }
8
8
  /**
@@ -31,6 +31,8 @@ type Token = {
31
31
  meta: {
32
32
  unclosed?: boolean;
33
33
  skippedCloser?: number;
34
+ legacyCloser?: number;
35
+ unverified?: boolean;
34
36
  } | null;
35
37
  };
36
38
  /**
@@ -39,9 +41,5 @@ type Token = {
39
41
  * missing brace or an unterminated quote loses attributes silently.
40
42
  */
41
43
  export declare function attributeProblem(block: string): string | undefined;
42
- /**
43
- * Diagnostics for a token stream. `lines` is the parsed text split at `\n`;
44
- * `lineOf` maps a 0-based line of it to a 1-based source line.
45
- */
46
44
  export declare function collectDiagnostics(tokens: Token[], lines: string[], lineOf: (line0: number) => number | undefined): ParseDiagnostic[];
47
45
  export {};
@@ -24,6 +24,50 @@ export const closesContainer = (line, colons) => line !== undefined && new RegEx
24
24
  export function closerLabel(comment) {
25
25
  return /^<!--\s*(?:end\s+|\/)?:*([A-Za-z][\w-]*)\s*-->$/.exec(comment.trim())?.[1];
26
26
  }
27
+ /**
28
+ * The line of an attribute block a list or list item was given, so the diagnostic points at the
29
+ * braces rather than the first item. In `from`..`to`, the last line that is only `{…}`, else the
30
+ * last that ends with one (code lines skipped). With `after` (a list's block may follow it, past
31
+ * blank lines), only when the list has none: then a line that is only `{…}`. Else `from`.
32
+ */
33
+ function braceLine(lines, code, from, to, after = false) {
34
+ const only = (l) => !code.has(l) && /^[\s>]*\{[^{}]*\}\s*$/.test(lines[l] ?? "");
35
+ const ends = (l) => !code.has(l) && /\{[^{}]*\}\s*$/.test(lines[l] ?? "");
36
+ const last = Math.min(to, lines.length - 1);
37
+ for (let l = last; l >= from; l--)
38
+ if (only(l))
39
+ return l;
40
+ for (let l = last; l >= from; l--)
41
+ if (ends(l))
42
+ return l;
43
+ if (after) {
44
+ let l = to;
45
+ while (l < lines.length && (lines[l] ?? "").trim() === "")
46
+ l++;
47
+ if (only(l))
48
+ return l;
49
+ }
50
+ return from;
51
+ }
52
+ /** A line with its inline code spans and `<…>` (raw HTML, autolinks) blanked, keeping offsets. */
53
+ function blankCodeAndTags(line) {
54
+ return line.replace(/(`+)[\s\S]*?\1|<[^<>]*>/g, (c) => " ".repeat(c.length));
55
+ }
56
+ /** Lines inside fenced or indented code, from the tokens. */
57
+ function codeLineSet(tokens) {
58
+ const out = new Set();
59
+ const walk = (list) => {
60
+ for (const t of list) {
61
+ if ((t.type === "fence" || t.type === "code_block") && t.map)
62
+ for (let l = t.map[0]; l < t.map[1]; l++)
63
+ out.add(l);
64
+ if (t.children)
65
+ walk(t.children);
66
+ }
67
+ };
68
+ walk(tokens);
69
+ return out;
70
+ }
27
71
  /** The inline directives the parser turns into nodes (parse.ts inlineDirectiveToNode); any other name stays literal text. */
28
72
  export const KNOWN_INLINE_DIRECTIVES = new Set(["kbd", "icon", "math", "mark", "highlight", "button", "badge"]);
29
73
  /** Directives that are blocks: `::name{…}` draws one only on a line of its own (parse.ts mapContainerToTreeNode). */
@@ -56,6 +100,20 @@ export function attributeProblem(block) {
56
100
  * Diagnostics for a token stream. `lines` is the parsed text split at `\n`;
57
101
  * `lineOf` maps a 0-based line of it to a 1-based source line.
58
102
  */
103
+ /** Why a `:::youtube` block is wrong (its own body, or another block's closer), or undefined if it is the empty block with its own closer. */
104
+ function youtubeAsBlock(map, closed, lines, around, lineOf) {
105
+ if (!closed)
106
+ return "is never closed, so it takes in everything after it";
107
+ if (lines.slice(map[0] + 1, map[1]).some((l) => l.replace(/^[\s>]*/, "") !== "")) {
108
+ return "is a block, and the lines up to its closer are dropped (a video shows no content)";
109
+ }
110
+ const parent = around[around.length - 1];
111
+ if (around.some((a) => a.map[1] === map[1]))
112
+ return `has no closer of its own: it ends with the :::${parent?.name ?? "block"} around it`;
113
+ if (parent && !parent.closed)
114
+ return `takes the closer on line ${lineOf(map[1]) ?? "?"}, which leaves :::${parent.name} open`;
115
+ return undefined;
116
+ }
59
117
  export function collectDiagnostics(tokens, lines, lineOf) {
60
118
  const out = [];
61
119
  const add = (line0, code, message) => {
@@ -63,8 +121,16 @@ export function collectDiagnostics(tokens, lines, lineOf) {
63
121
  if (line !== undefined)
64
122
  out.push({ line, code, message });
65
123
  };
124
+ const code = codeLineSet(tokens);
125
+ let row;
126
+ // The `:::` blocks around the current token, outermost first
127
+ const around = [];
66
128
  const walk = (list) => {
67
- for (const tok of list) {
129
+ // Indexed: i per token made the diagnostics quadratic in the page's length
130
+ for (let i = 0; i < list.length; i++) {
131
+ const tok = list[i];
132
+ if (/^container_.+_close$/.test(tok.type))
133
+ around.pop();
68
134
  const open = /^container_(.+)_open$/.exec(tok.type);
69
135
  if (open && tok.map) {
70
136
  const generic = open[1] === GENERIC_CONTAINER;
@@ -73,7 +139,19 @@ export function collectDiagnostics(tokens, lines, lineOf) {
73
139
  const colons = (tok.markup ?? ":::").length;
74
140
  const closed = closesContainer(lines[tok.map[1]], colons);
75
141
  const skipped = tok.meta?.skippedCloser;
76
- if (!closed && skipped !== undefined) {
142
+ const legacy = tok.meta?.legacyCloser;
143
+ // A video has no body, so `:::youtube` is a block that has taken in what follows it, or a
144
+ // closer meant for another block. The leaf form says "no body" in the syntax
145
+ // (plans/youtube-leaf.md). A blank line in a quote is `>`, so quote markers are not content.
146
+ const leafAsBlock = name === "youtube" ? youtubeAsBlock(tok.map, closed, lines, around, lineOf) : undefined;
147
+ if (leafAsBlock) {
148
+ add(tok.map[0], "leaf-as-block", `a video has no body: write ::youtube{…} on one line, with no closer — as :::youtube it ${leafAsBlock}`);
149
+ }
150
+ else if (!closed && legacy !== undefined && !generic) {
151
+ // Before the nesting stack, the closer on that line ended this block and the ones inside it
152
+ add(tok.map[0], "unclosed-container", `:::${name} is never closed — the ${lines[legacy]?.trim()} on line ${lineOf(legacy) ?? "?"} closes only the innermost open block; each block needs its own closer (one \`:::\` used to close several)`);
153
+ }
154
+ else if (!closed && skipped !== undefined) {
77
155
  add(tok.map[0], "unclosed-container", `:::${name} is never closed — the ${lines[skipped]?.trim()} on line ${lineOf(skipped) ?? "?"} is inside a code block, so it does not close it (add a closer after the code)`);
78
156
  }
79
157
  else if (!closed) {
@@ -84,6 +162,9 @@ export function collectDiagnostics(tokens, lines, lineOf) {
84
162
  else if (generic) {
85
163
  add(tok.map[0], "unknown-container", `:::${name} is not a known directive — it is shown as a plain box`);
86
164
  }
165
+ if (tok.meta?.unverified) {
166
+ add(tok.map[0], "nesting-unchecked", `the parser stopped re-checking the nested blocks on this page here, so where :::${name} ends may be wrong — check that each block inside it has its own closer, or split the page`);
167
+ }
87
168
  if (closed) {
88
169
  // The closer's end-tag label: it closes this container whatever it says, so a wrong
89
170
  // name means the author thinks it closes another one
@@ -98,13 +179,14 @@ export function collectDiagnostics(tokens, lines, lineOf) {
98
179
  if (problem)
99
180
  add(tok.map[0], "bad-attributes", `the attributes of :::${name} ${problem} — they are ignored`);
100
181
  }
182
+ // An unknown block never closed is read back as text, so it is no parent (but still popped)
183
+ around.push({ map: tok.map, closed: closed || (generic && tok.meta?.unclosed === true), name });
101
184
  }
102
- // A `:::` line markdown-it-container did not take as a closer: the container it was
103
- // meant to close ended earlier (an inner block of the same fence closed it), or there is none
185
+ // A `:::` line no open block took: every block it could close is already closed
104
186
  if (tok.type === "inline" && tok.map) {
105
187
  (tok.content ?? "").split("\n").forEach((l, k) => {
106
188
  if (/^\s*:{3,}[ \t]*(?:<!--(?:(?!-->)[\s\S])*-->[ \t]*)?$/.test(l)) {
107
- add(tok.map[0] + k, "stray-closer", `stray \`${l.trim()}\` — it closes nothing and is shown as text; to nest a directive, give the outer one more colons (::::steps around :::tabs)`);
189
+ add(tok.map[0] + k, "stray-closer", `stray \`${l.trim()}\` — it closes nothing and is shown as text: every block before it is already closed (each block takes one closer)`);
108
190
  }
109
191
  });
110
192
  }
@@ -132,13 +214,13 @@ export function collectDiagnostics(tokens, lines, lineOf) {
132
214
  // Attribute blocks markdown-it-attrs read and the tree has nowhere to keep
133
215
  if (tok.type === "list_item_open" && tok.map && tok.attrs?.length) {
134
216
  // parse.ts keepListItemBraces puts trailing braces back as text, or on a heading; what is left had nowhere to go
135
- add(tok.map[0], "dropped-attributes", `the attribute block on this list item is dropped — list items take no attributes`);
217
+ add(braceLine(lines, code, tok.map[0], tok.map[1] - 1), "dropped-attributes", `the attribute block on this list item is dropped — list items take no attributes`);
136
218
  }
137
219
  // A table row that is only an attribute block, where the parser could not move it onto the
138
220
  // table (not the last row, or text follows the table with no blank line): an empty row
139
221
  if (tok.type === "tr_open" && tok.map) {
140
222
  const cells = [];
141
- for (let k = list.indexOf(tok) + 1; k < list.length && list[k].type !== "tr_close"; k++)
223
+ for (let k = i + 1; k < list.length && list[k].type !== "tr_close"; k++)
142
224
  cells.push(list[k]);
143
225
  const inlines = cells.filter((c) => c.type === "inline");
144
226
  if (cells[0]?.attrs?.length && /^\{[^{}]*\}$/.test((inlines[0]?.content ?? "").trim()) && inlines.slice(1).every((c) => c.content.trim() === "")) {
@@ -149,11 +231,11 @@ export function collectDiagnostics(tokens, lines, lineOf) {
149
231
  // Only real attribute names: markdown-it-attrs also reads a leftover include (`{* src.py hl[4] *}`, FastAPI) as keys
150
232
  const own = tok.attrs?.filter(([k]) => k !== "start") ?? [];
151
233
  if ((tok.type === "bullet_list_open" || tok.type === "ordered_list_open") && tok.map && own.length > 0 && own.every(([k]) => /^[A-Za-z_][\w:.-]*$/.test(k))) {
152
- add(tok.map[0], "dropped-attributes", `the attribute block on this list is dropped — lists take no attributes`);
234
+ add(braceLine(lines, code, tok.map[0], tok.map[1], true), "dropped-attributes", `the attribute block on this list is dropped — lists take no attributes`);
153
235
  }
154
236
  // An attribute block alone on its line after a blank line: an empty paragraph that carries it
155
237
  if (tok.type === "paragraph_open" && tok.map && tok.attrs?.length) {
156
- const inline = list[list.indexOf(tok) + 1];
238
+ const inline = list[i + 1];
157
239
  // Only what an author writes as attributes: `{#id}`, `{.class}`, `{key=…}`. markdown-it-attrs also eats a
158
240
  // leftover MDX expression (`{/* prettier-ignore */}`), which is an importer's problem, not the author's
159
241
  const looksLikeAttrs = /^\{\s*(?:[#.][^\s{}]|[\w-]+\s*=)/.test(inline?.content.trim() ?? "");
@@ -181,20 +263,58 @@ export function collectDiagnostics(tokens, lines, lineOf) {
181
263
  }
182
264
  }
183
265
  // `:kbd{…}` with no `[label]` is not a directive: the label is what it shows
266
+ const missing = (name) => `:${name}{…} has no [label] — it is shown as text; write :${name}[…]{…}`;
267
+ const inText = new Set();
268
+ // An autolink's text is its URL, not prose
269
+ let autolink = 0;
184
270
  for (const child of tok.children) {
185
- if (child.type !== "text")
271
+ if (child.type === "link_open" && (child.markup === "autolink" || child.markup === "linkify"))
272
+ autolink++;
273
+ if (child.type === "link_close" && autolink > 0)
274
+ autolink--;
275
+ if (child.type !== "text" || autolink > 0)
186
276
  continue;
187
277
  for (const m of child.content.matchAll(/:([a-z]+)\{/g)) {
188
278
  if (m.index > 0 && PRECEDES_TEXT.test(child.content[m.index - 1]))
189
279
  continue;
190
- if (KNOWN_INLINE_DIRECTIVES.has(m[1]))
191
- add(tok.map[0], "missing-label", `:${m[1]}{…} has no [label] — it is shown as text; write :${m[1]}[…]{…}`);
280
+ if (KNOWN_INLINE_DIRECTIVES.has(m[1])) {
281
+ inText.add(m[1]);
282
+ let at = tok.map[0];
283
+ while (at < tok.map[1] - 1 && !(lines[at] ?? "").includes(`:${m[1]}{`))
284
+ at++;
285
+ add(at, "missing-label", missing(m[1]));
286
+ }
287
+ }
288
+ }
289
+ // At the end of a paragraph, heading or cell, markdown-it-attrs takes the braces for the
290
+ // block's own, leaving `:kbd` in the text: read the source lines for what is no longer there
291
+ if (list[i - 1]?.attrs?.length) {
292
+ for (let l = tok.map[0]; l < tok.map[1]; l++) {
293
+ const line = blankCodeAndTags(lines[l] ?? "");
294
+ for (const m of line.matchAll(/:([a-z]+)\{/g)) {
295
+ if (m.index > 0 && PRECEDES_TEXT.test(line[m.index - 1]))
296
+ continue;
297
+ if (KNOWN_INLINE_DIRECTIVES.has(m[1]) && !inText.has(m[1]) && !line.startsWith(`:${m[1]}[`, m.index)) {
298
+ inText.add(m[1]);
299
+ add(l, "missing-label", missing(m[1]));
300
+ }
301
+ }
192
302
  }
193
303
  }
194
304
  }
305
+ // A table cell's inline token has no line map: its row's has
306
+ if (tok.type === "tr_open" && tok.map)
307
+ row = tok.map[0];
308
+ if (tok.type === "inline" && !tok.map && row !== undefined && /^t[hd]_open$/.test(list[i - 1]?.type ?? "") && list[i - 1]?.attrs?.length) {
309
+ const cell = blankCodeAndTags(tok.content);
310
+ const m = /:([a-z]+)\{/.exec(cell);
311
+ if (m && KNOWN_INLINE_DIRECTIVES.has(m[1]) && !(m.index > 0 && PRECEDES_TEXT.test(cell[m.index - 1]))) {
312
+ add(row, "missing-label", `:${m[1]}{…} has no [label] — it is shown as text; write :${m[1]}[…]{…}`);
313
+ }
314
+ }
195
315
  // A heading's attribute block that markdown-it-attrs could not read stays in its text
196
316
  if (tok.type === "heading_open" && tok.map) {
197
- const inline = list[list.indexOf(tok) + 1];
317
+ const inline = list[i + 1];
198
318
  // markdown-it-attrs takes a block it reads out of the text tokens (not out of `content`)
199
319
  const last = [...(inline?.children ?? [])].reverse().find((c) => c.type === "text");
200
320
  const tail = /\{\s*(?:[#.][^{}]*|[\w-]+\s*=[^{}]*)\}?\s*$/.exec(last?.content ?? "");
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
2
2
  export type { SerializeOptions } from "./types.js";
3
3
  export { DOGSBAY_MD_CAPABILITIES } from "./capabilities.js";
4
4
  export { inlineToDogsbayMd } from "./inline.js";
5
- export { dogsbayMdToTree, parseInline } from "./parse.js";
5
+ export { dogsbayMdToTree, parseInline, containerOpenBefore } from "./parse.js";
6
6
  export { extractHeadings, slugifyHeadingText, type HeadingInfo } from "./headings.js";
7
7
  export { transformCodeRegions, transformProseLines, transformProseSegments, fenceOpenBefore, createFenceTracker } from "./code-regions.js";
8
8
  export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcessedFence, stripInlineMinjaMarkers, stripFenceMinjaMarkers } from "./minja-mask.js";
@@ -14,6 +14,7 @@ export type { BuildNavOptions } from "./nav.js";
14
14
  export { loadNavFile } from "./nav-file.js";
15
15
  export type { NavFile, NavFileItem, LoadNavFileOptions, MissingNavTarget, } from "./nav-file.js";
16
16
  export { renderDiagrams } from "./renderers/diagrams.js";
17
- export type { RenderDiagramsOptions } from "./renderers/diagrams.js";
17
+ export type { RenderDiagramsOptions, DiagramEngine } from "./renderers/diagrams.js";
18
+ export { renderPeleSvg, diagramId } from "./renderers/pele.js";
18
19
  export { renderMermaidSvg, setMmdcOverride, findMmdc } from "./renderers/mermaid.js";
19
20
  export { TemplatePages, TemplatePageError, declaresParams, ATTRIBUTE_BLOCK_OPEN, type TemplatePagesOptions } from "./template-pages.js";
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
6
6
  export { DOGSBAY_MD_CAPABILITIES } from "./capabilities.js";
7
7
  export { inlineToDogsbayMd } from "./inline.js";
8
- export { dogsbayMdToTree, parseInline } from "./parse.js";
8
+ export { dogsbayMdToTree, parseInline, containerOpenBefore } from "./parse.js";
9
9
  export { extractHeadings, slugifyHeadingText } from "./headings.js";
10
10
  export { transformCodeRegions, transformProseLines, transformProseSegments, fenceOpenBefore, createFenceTracker } from "./code-regions.js";
11
11
  export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcessedFence, stripInlineMinjaMarkers, stripFenceMinjaMarkers } from "./minja-mask.js";
@@ -15,5 +15,6 @@ export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
15
15
  export { buildNavFromDirectory } from "./nav.js";
16
16
  export { loadNavFile } from "./nav-file.js";
17
17
  export { renderDiagrams } from "./renderers/diagrams.js";
18
+ export { renderPeleSvg, diagramId } from "./renderers/pele.js";
18
19
  export { renderMermaidSvg, setMmdcOverride, findMmdc } from "./renderers/mermaid.js";
19
20
  export { TemplatePages, TemplatePageError, declaresParams, ATTRIBUTE_BLOCK_OPEN } from "./template-pages.js";
@@ -21,3 +21,12 @@ export declare function parseAttrs(inner: string): ParsedAttrs;
21
21
  * `id` and `classes` become `id` and `class`; other props flow through.
22
22
  */
23
23
  export declare function attrsToProps(attrs: ParsedAttrs): Record<string, unknown>;
24
+ /**
25
+ * The index of the `}` that closes an attribute block whose `{` is just before `start`, or -1.
26
+ * Braces inside a double-quoted value (`title="a } b"`, with `\"` escapes, as escapeAttrValue
27
+ * writes them) do not count: a brace-only count ended a leaf `::youtube{#a title="a } b"}` at the
28
+ * first `}` and lost the video. A `"` opens a value only straight after `=`, as parseAttrs reads
29
+ * it: a stray `"` in an unquoted value (`title=5"`) is a character, or the scan would run on through
30
+ * the paragraph. Single quotes are not quotes here, so an apostrophe reads as before.
31
+ */
32
+ export declare function attrBlockEnd(src: string, start: number, max: number): number;
@@ -114,3 +114,31 @@ export function attrsToProps(attrs) {
114
114
  }
115
115
  return props;
116
116
  }
117
+ /**
118
+ * The index of the `}` that closes an attribute block whose `{` is just before `start`, or -1.
119
+ * Braces inside a double-quoted value (`title="a } b"`, with `\"` escapes, as escapeAttrValue
120
+ * writes them) do not count: a brace-only count ended a leaf `::youtube{#a title="a } b"}` at the
121
+ * first `}` and lost the video. A `"` opens a value only straight after `=`, as parseAttrs reads
122
+ * it: a stray `"` in an unquoted value (`title=5"`) is a character, or the scan would run on through
123
+ * the paragraph. Single quotes are not quotes here, so an apostrophe reads as before.
124
+ */
125
+ export function attrBlockEnd(src, start, max) {
126
+ let depth = 1;
127
+ let quoted = false;
128
+ for (let i = start; i < max; i++) {
129
+ const c = src.charCodeAt(i);
130
+ if (quoted) {
131
+ if (c === 0x5c /* \ */)
132
+ i++;
133
+ else if (c === 0x22 /* " */)
134
+ quoted = false;
135
+ }
136
+ else if (c === 0x22 && i > start && src.charCodeAt(i - 1) === 0x3d /* = */)
137
+ quoted = true;
138
+ else if (c === 0x7b /* { */)
139
+ depth++;
140
+ else if (c === 0x7d /* } */ && --depth === 0)
141
+ return i;
142
+ }
143
+ return -1;
144
+ }
package/dist/parse.d.ts CHANGED
@@ -42,3 +42,12 @@ export declare function dogsbayMdToTree(source: string, options?: ParseOptions):
42
42
  * Parse a single inline string to InlineNode[] (useful for testing).
43
43
  */
44
44
  export declare function parseInline(source: string): InlineNode[];
45
+ /**
46
+ * Per line of `source` (the body, no front matter, split at `\n` as the commands engine splits
47
+ * it): whether the line is inside a `:::` block as the parser nests them. The opener's line is
48
+ * not, nor the closer's; the body between is. Read from the
49
+ * parse rather than re-derived, so the commands engine's outline (a heading in a block is never
50
+ * structure) cannot drift from the parser: a line scan cannot see where a list item ends a block,
51
+ * which names are blocks, or raw HTML. An unknown name never closed is text, so not a block.
52
+ */
53
+ export declare function containerOpenBefore(source: string): boolean[];
package/dist/parse.js CHANGED
@@ -388,6 +388,33 @@ function keepListItemBraces(tokens, src) {
388
388
  tok.attrs = null;
389
389
  });
390
390
  }
391
+ /**
392
+ * Per line of `source` (the body, no front matter, split at `\n` as the commands engine splits
393
+ * it): whether the line is inside a `:::` block as the parser nests them. The opener's line is
394
+ * not, nor the closer's; the body between is. Read from the
395
+ * parse rather than re-derived, so the commands engine's outline (a heading in a block is never
396
+ * structure) cannot drift from the parser: a line scan cannot see where a list item ends a block,
397
+ * which names are blocks, or raw HTML. An unknown name never closed is text, so not a block.
398
+ */
399
+ export function containerOpenBefore(source) {
400
+ // As markdown-it's normalize step (which block.parse skips): \r\n is a line break. A lone \r is
401
+ // one to markdown-it but not to the engine's split at \n, so it becomes a space
402
+ const src = source.replace(/\r\n/g, "\n").replace(/\r/g, " ");
403
+ const inside = new Array(src.split("\n").length).fill(false);
404
+ // block rules only: the inline pass changes no line a block spans
405
+ const md = sharedParser();
406
+ const tokens = [];
407
+ md.block.parse(src, md, {}, tokens);
408
+ markUnclosedGenericContainers(tokens, src);
409
+ for (const tok of tokens) {
410
+ const map = tok.map;
411
+ if (tok.nesting !== 1 || !tok.type.startsWith("container_") || !map || tok.meta?.unclosed)
412
+ continue;
413
+ for (let l = map[0] + 1; l < map[1] && l < inside.length; l++)
414
+ inside[l] = true;
415
+ }
416
+ return inside;
417
+ }
391
418
  /**
392
419
  * markdown-it-container runs a block with no closing marker to the end of the document. For a
393
420
  * generic (unknown-name) container that would turn any prose line starting `:::word` into a block
@@ -1,4 +1,4 @@
1
- import { parseAttrs } from "./parse-attrs.js";
1
+ import { attrBlockEnd, parseAttrs } from "./parse-attrs.js";
2
2
  export function blockLeafDirectivePlugin(md) {
3
3
  md.block.ruler.before("paragraph", "dogsbay_block_leaf_directive", blockLeafRule);
4
4
  }
@@ -42,20 +42,8 @@ function blockLeafRule(state, startLine, endLine, silent) {
42
42
  let attrsStr = "";
43
43
  if (state.src.charCodeAt(pos) === 0x7b /* { */) {
44
44
  const attrStart = pos + 1;
45
- let depth = 1;
46
- let ap = attrStart;
47
- while (ap < max) {
48
- const c = state.src.charCodeAt(ap);
49
- if (c === 0x7b)
50
- depth++;
51
- else if (c === 0x7d) {
52
- depth--;
53
- if (depth === 0)
54
- break;
55
- }
56
- ap++;
57
- }
58
- if (ap >= max)
45
+ const ap = attrBlockEnd(state.src, attrStart, max);
46
+ if (ap < 0)
59
47
  return false; // unterminated
60
48
  attrsStr = state.src.slice(attrStart, ap);
61
49
  pos = ap + 1;
@@ -2,9 +2,8 @@
2
2
  * Register the container rule (container-rule.ts, adapted from markdown-it-container) for each
3
3
  * known Dogsbay block directive.
4
4
  *
5
- * Each directive name is
6
- * registered separately; within same colon count, different-name directives can
7
- * nest. Same-name nesting requires more colons (::::).
5
+ * Each directive name is registered separately. Blocks nest at any colon count, the same name
6
+ * too: the rule keeps a stack of the blocks open inside it (NESTING in container-rule.ts).
8
7
  */
9
8
  import type MarkdownIt from "markdown-it";
10
9
  export declare const KNOWN_BLOCK_DIRECTIVES: readonly ["tabs", "tab", "steps", "step", "cards", "card", "grid", "grid-item", "details", "callout", "note", "tip", "info", "important", "warning", "caution", "danger", "success", "hint", "attention", "error", "failure", "bug", "question", "abstract", "quote", "button", "figure", "math", "api-endpoint", "api-params", "api-response", "config", "files", "example", "accordion", "accordion-item", "link-card", "autodoc", "api-class", "api-member", "api-doc", "api-source", "youtube", "openapi", "theme"];
@@ -1,4 +1,4 @@
1
- import { containerRule } from "./container-rule.js";
1
+ import { containerRule, registerOpener } from "./container-rule.js";
2
2
  import { parseAttrs } from "./parse-attrs.js";
3
3
  export const KNOWN_BLOCK_DIRECTIVES = [
4
4
  "tabs",
@@ -54,8 +54,9 @@ export const KNOWN_BLOCK_DIRECTIVES = [
54
54
  "api-member",
55
55
  "api-doc",
56
56
  "api-source",
57
- // Lazy-loading video embed (YouTube). Leaf component — no body, just
58
- // `:::youtube{#<videoId> title="…"}`. Renders via @ui/youtube/YouTube.astro.
57
+ // Lazy-loading video embed (YouTube). No body, so written as the leaf
58
+ // `::youtube{#<videoId> title="…"}`; the block form here is the empty
59
+ // `:::youtube{…}` + `:::` earlier serializers wrote. Renders via @ui/youtube/YouTube.astro.
59
60
  "youtube",
60
61
  // An OpenAPI spec embedded in a page: `:::openapi{spec="api.yaml"}`,
61
62
  // expanded into endpoint cards from the spec at build time (the
@@ -72,6 +73,7 @@ export const KNOWN_BLOCK_DIRECTIVES = [
72
73
  export const GENERIC_CONTAINER = "dogsbay_generic";
73
74
  export function containersPlugin(md) {
74
75
  for (const name of KNOWN_BLOCK_DIRECTIVES) {
76
+ registerOpener(name);
75
77
  containerRule(md, name, {
76
78
  validate: (params) => {
77
79
  const trimmed = params.trim();
@@ -1,4 +1,4 @@
1
- import { parseAttrs } from "./parse-attrs.js";
1
+ import { attrBlockEnd, parseAttrs } from "./parse-attrs.js";
2
2
  /** A character a directive cannot follow directly (see inlineDirectiveRule). */
3
3
  export const PRECEDES_TEXT = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}0-9:]/u;
4
4
  export function inlineDirectivesPlugin(md) {
@@ -78,20 +78,8 @@ function inlineDirectiveRule(state, silent) {
78
78
  let attrs = "";
79
79
  if (src.charCodeAt(pos) === 0x7b /* { */) {
80
80
  const attrStart = pos + 1;
81
- let depth = 1;
82
- let ap = attrStart;
83
- while (ap < src.length) {
84
- const c = src.charCodeAt(ap);
85
- if (c === 0x7b)
86
- depth++;
87
- else if (c === 0x7d) {
88
- depth--;
89
- if (depth === 0)
90
- break;
91
- }
92
- ap++;
93
- }
94
- if (ap < src.length) {
81
+ const ap = attrBlockEnd(src, attrStart, src.length);
82
+ if (ap >= 0) {
95
83
  attrs = src.slice(attrStart, ap);
96
84
  pos = ap + 1;
97
85
  }
@@ -1,5 +1,9 @@
1
1
  import type { TreeNode } from "@dogsbay/types";
2
+ /** Who draws Mermaid fences (`content.diagrams`): mmdc, as Mermaid draws them, or Pele, themed. */
3
+ export type DiagramEngine = "mermaid-cli" | "pele";
2
4
  export interface RenderDiagramsOptions {
5
+ /** Default `mermaid-cli`. */
6
+ engine?: DiagramEngine;
3
7
  /**
4
8
  * Suppress the one-time "mmdc not found" warning printed when
5
9
  * the binary can't be resolved. Default false. Tests pass true
@@ -1,4 +1,5 @@
1
1
  import { renderMermaidSvg } from "./mermaid.js";
2
+ import { diagramId, renderPeleSvg } from "./pele.js";
2
3
  const DIAGRAM_LANGS = new Set(["mermaid"]);
3
4
  /**
4
5
  * Walk a TreeNode tree and replace `code` blocks with a diagram
@@ -16,6 +17,10 @@ const DIAGRAM_LANGS = new Set(["mermaid"]);
16
17
  */
17
18
  export async function renderDiagrams(tree, options) {
18
19
  let warned = false;
20
+ const pele = options?.engine === "pele";
21
+ // Pele: a diagram shown twice on the page gets its own ids the second time
22
+ const seen = new Map();
23
+ const failed = [];
19
24
  async function walk(nodes) {
20
25
  for (const node of nodes) {
21
26
  if (node.children)
@@ -42,7 +47,20 @@ export async function renderDiagrams(tree, options) {
42
47
  : "";
43
48
  if (!code)
44
49
  continue;
45
- const svg = await renderMermaidSvg(code);
50
+ let svg;
51
+ if (pele) {
52
+ const base = diagramId(code);
53
+ const n = (seen.get(base) ?? 0) + 1;
54
+ seen.set(base, n);
55
+ const r = await renderPeleSvg(code, n === 1 ? base : `${base}-${n}`);
56
+ if ("error" in r) {
57
+ failed.push(r.error);
58
+ continue;
59
+ }
60
+ svg = r.svg;
61
+ }
62
+ else
63
+ svg = await renderMermaidSvg(code);
46
64
  if (svg === null) {
47
65
  if (!warned && !options?.silent) {
48
66
  console.warn("[diagrams] mmdc (mermaid-cli) not installed or render " +
@@ -67,4 +85,8 @@ export async function renderDiagrams(tree, options) {
67
85
  }
68
86
  }
69
87
  await walk(tree);
88
+ if (failed.length > 0 && !options?.silent) {
89
+ console.warn(`[diagrams] Pele could not draw ${failed.length} diagram(s), left as code blocks: ` +
90
+ failed[0].split("\n")[0].slice(0, 160));
91
+ }
70
92
  }
@@ -0,0 +1,13 @@
1
+ /** Longest diagram source drawn; a longer one is refused rather than laid out (as dogsbay-ssg). */
2
+ export declare const MAX_DIAGRAM_CHARS = 100000;
3
+ /**
4
+ * Draw one diagram. `id` prefixes the ids Pele writes (a title and description, for
5
+ * aria-labelledby), so two diagrams on a page do not share them.
6
+ */
7
+ export declare function renderPeleSvg(code: string, id: string): Promise<{
8
+ svg: string;
9
+ } | {
10
+ error: string;
11
+ }>;
12
+ /** A stable id from the source, valid in XML (`dg-` and hex). */
13
+ export declare function diagramId(code: string): string;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Mermaid drawn by Pele (`content.diagrams: pele`; plans/pele-diagrams.md): plain JavaScript,
3
+ * synchronous, no DOM and no browser, unlike mmdc. Its colours and fonts are CSS variables
4
+ * (`--pele-*`), which DocsLayout maps to the site theme, so a diagram follows light and dark mode
5
+ * instead of being inverted.
6
+ *
7
+ * Loaded with import() on the first diagram: a site without diagrams, or one drawn by mmdc,
8
+ * never loads it. dogsbay-ssg draws diagrams the same way (its core/pele.ts).
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ /** Longest diagram source drawn; a longer one is refused rather than laid out (as dogsbay-ssg). */
12
+ export const MAX_DIAGRAM_CHARS = 100_000;
13
+ let pele;
14
+ /**
15
+ * Draw one diagram. `id` prefixes the ids Pele writes (a title and description, for
16
+ * aria-labelledby), so two diagrams on a page do not share them.
17
+ */
18
+ export async function renderPeleSvg(code, id) {
19
+ if (code.length > MAX_DIAGRAM_CHARS)
20
+ return { error: `the diagram is longer than ${MAX_DIAGRAM_CHARS} characters` };
21
+ let p;
22
+ try {
23
+ p = await (pele ??= import("pele"));
24
+ }
25
+ catch (e) {
26
+ pele = undefined;
27
+ return { error: `could not load Pele (pele): ${e instanceof Error ? e.message : String(e)}` };
28
+ }
29
+ try {
30
+ // No images: an `img:` node would have every reader fetch its URL (a third-party one too),
31
+ // and a local path is neither copied as an asset nor given the site's base path
32
+ const { svg } = p.render(code, { idPrefix: id, limit: MAX_DIAGRAM_CHARS, images: false });
33
+ return svg ? { svg } : { error: "Pele returned no SVG" };
34
+ }
35
+ catch (e) {
36
+ return { error: e instanceof Error ? e.message : String(e) };
37
+ }
38
+ }
39
+ /** A stable id from the source, valid in XML (`dg-` and hex). */
40
+ export function diagramId(code) {
41
+ return "dg-" + createHash("sha256").update(code).digest("hex").slice(0, 12);
42
+ }
package/dist/serialize.js CHANGED
@@ -116,8 +116,9 @@ function renderNodeBody(node, ctx) {
116
116
  case "button":
117
117
  return renderButton(node, ctx);
118
118
  case "youtube":
119
- // Leaf embed — `:::youtube{#<id> title="…"}` with no body.
120
- return renderBlockDirective({ name: "youtube", props: { ...(node.props ?? {}), id: node.props?.id }, body: "" }, ctx);
119
+ // A video has no body, so it is a leaf: `::youtube{#<id> title="…"}`. `:::` always opens a
120
+ // block that needs a closer (the empty `:::youtube` … `:::` form still reads).
121
+ return `::youtube${renderAttrs(splitProps({ ...(node.props ?? {}), id: node.props?.id }, []))}`;
121
122
  case "deflist":
122
123
  return renderDeflist(node, ctx);
123
124
  case "theme":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/format-dogsbay-md",
3
- "version": "0.2.0-beta.126",
3
+ "version": "0.2.0-beta.128",
4
4
  "description": "Dogsbay Markdown dialect — canonical format for TreeNode[] with directives, YAML bodies, and intelligent fallbacks",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -24,12 +24,12 @@
24
24
  "README.md"
25
25
  ],
26
26
  "devDependencies": {
27
+ "@mermaid-js/mermaid-cli": "^11.16.0",
27
28
  "@types/js-yaml": "^4.0.9",
28
29
  "@types/markdown-it": "^14.1.2",
29
30
  "@types/node": "^25.9.5",
30
31
  "typescript": "^5.9.3",
31
- "vitest": "^4.1.10",
32
- "@mermaid-js/mermaid-cli": "^11.16.0"
32
+ "vitest": "^4.1.10"
33
33
  },
34
34
  "dependencies": {
35
35
  "gray-matter": "^4.0.3",
@@ -39,9 +39,10 @@
39
39
  "markdown-it-deflist": "^3.0.1",
40
40
  "markdown-it-footnote": "^4.0.0",
41
41
  "markdown-it-front-matter": "^0.2.4",
42
- "@dogsbay/minja": "0.2.0-beta.126",
43
- "@dogsbay/serialize-core": "0.2.0-beta.126",
44
- "@dogsbay/types": "0.2.0-beta.126"
42
+ "pele": "1.6.0",
43
+ "@dogsbay/minja": "0.2.0-beta.128",
44
+ "@dogsbay/serialize-core": "0.2.0-beta.128",
45
+ "@dogsbay/types": "0.2.0-beta.128"
45
46
  },
46
47
  "license": "MIT",
47
48
  "repository": {