@dogsbay/format-dogsbay-md 0.2.0-beta.127 → 0.2.0-beta.129

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
@@ -13,7 +13,7 @@
13
13
  */
14
14
  export { dogsbayMdToTree, parseInline, containerOpenBefore, type ParseResult, type ParseOptions } from "./parse.js";
15
15
  export { KNOWN_INLINE_DIRECTIVES, type ParseDiagnostic } from "./diagnostics.js";
16
- export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
16
+ export { KNOWN_BLOCK_DIRECTIVES, LEAF_ONLY_DIRECTIVES, BLOCK_LEAF_DIRECTIVES } from "./plugin-containers.js";
17
17
  export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
18
18
  export type { SerializeOptions } from "./types.js";
19
19
  export { inlineToDogsbayMd } from "./inline.js";
package/dist/browser.js CHANGED
@@ -14,7 +14,7 @@
14
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
- export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
17
+ export { KNOWN_BLOCK_DIRECTIVES, LEAF_ONLY_DIRECTIVES, BLOCK_LEAF_DIRECTIVES } from "./plugin-containers.js";
18
18
  export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
19
19
  export { inlineToDogsbayMd } from "./inline.js";
20
20
  export { transformCodeRegions, transformProseLines, transformProseSegments, fenceOpenBefore, createFenceTracker, } from "./code-regions.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)));
@@ -58,8 +58,9 @@ import type MarkdownIt from "markdown-it";
58
58
  * Only a known directive name opens a block in the scan. An unknown `:::word` is a block only if
59
59
  * it has a closer of its own and is otherwise text (markUnclosedGenericContainers), and the scan
60
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. A body-less block (`:::youtube{#id}`) is
62
- * pushed only when a closer that fits it follows, or it would take its parent's.
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.
63
64
  *
64
65
  * VERIFY. The scan reads raw lines, and markdown-it may not read an opener there as one: in raw
65
66
  * HTML, a `$$` math block, a fence that never closes. Where that is depends on where the blocks
@@ -77,11 +78,6 @@ export interface ContainerOptions {
77
78
  validate: (params: string, markup: string) => boolean;
78
79
  render?: (tokens: unknown[], idx: number) => string;
79
80
  }
80
- /**
81
- * Make `name` an opener for the nesting scan (the known directives; not the generic container).
82
- * A `leaf` is written with no body (`:::youtube{#id}`) and often with no closer.
83
- */
84
- export declare function registerOpener(name: string, options?: {
85
- leaf?: boolean;
86
- }): void;
81
+ /** Make `name` an opener for the nesting scan (the known directives; not the generic container). */
82
+ export declare function registerOpener(name: string): void;
87
83
  export declare function containerRule(md: MarkdownIt, name: string, options: ContainerOptions): void;
@@ -52,31 +52,16 @@ function closesFence(line, markup, after) {
52
52
  const m = /^[ \t>]*(`{3,}|~{3,})[ \t]*$/.exec(line);
53
53
  return m !== null && m[1][0] === markup[0] && m[1].length >= markup.length;
54
54
  }
55
- /** The directive names the scan treats as openers (plugin-containers registers them), and whether each is body-less. */
56
- const KNOWN_OPENERS = new Map();
57
- /** The known directive the text after an opener's colons starts with. */
58
- function knownOpener(rest) {
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
59
  const m = /^\s*([A-Za-z][\w-]*)(?=$|[\s{[])/.exec(rest);
60
- return m === null ? undefined : KNOWN_OPENERS.get(m[1]);
60
+ return m !== null && KNOWN_OPENERS.has(m[1]);
61
61
  }
62
- /**
63
- * Make `name` an opener for the nesting scan (the known directives; not the generic container).
64
- * A `leaf` is written with no body (`:::youtube{#id}`) and often with no closer.
65
- */
66
- export function registerOpener(name, options = {}) {
67
- KNOWN_OPENERS.set(name, { leaf: options.leaf === true });
68
- }
69
- /** Whether the next non-blank line after `line`, before `endLine`, is a closer for a block opened with `colons`. */
70
- function closerFollows(state, line, endLine, colons) {
71
- for (let l = line + 1; l < endLine; l++) {
72
- const start = state.bMarks[l] + state.tShift[l];
73
- const max = state.eMarks[l];
74
- if (start >= max)
75
- continue;
76
- const m = /^(:{3,})(.*)$/.exec(state.src.slice(start, max));
77
- return m !== null && m[1].length >= colons && CLOSER_TAIL.test(m[2]);
78
- }
79
- return false;
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);
80
65
  }
81
66
  /** Tokens whose lines are not block openers: an opener the scan pushed inside one is wrong (VERIFY). */
82
67
  const READ_AS_OTHER = new Set(["paragraph_open", "heading_open", "html_block", "fence", "code_block", "math_block", "hr", "table_open"]);
@@ -177,8 +162,11 @@ export function containerRule(md, name, options) {
177
162
  if (wrong.length === 0 && missed.length === 0)
178
163
  break;
179
164
  const budget = retryBudget(state);
180
- if (budget.lines < scan.nextLine - startLine)
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 };
181
168
  break;
169
+ }
182
170
  budget.lines -= scan.nextLine - startLine;
183
171
  for (const l of wrong)
184
172
  notOpeners.add(l);
@@ -238,7 +226,7 @@ export function containerRule(md, name, options) {
238
226
  continue;
239
227
  const rest = state.src.slice(pos, max);
240
228
  const tail = CLOSER_TAIL.exec(rest);
241
- const opener = tail ? undefined : knownOpener(rest);
229
+ const opener = !tail && isKnownOpener(rest);
242
230
  if (!tail && !opener)
243
231
  continue;
244
232
  // a `:::` in a code example is code
@@ -248,7 +236,7 @@ export function containerRule(md, name, options) {
248
236
  continue;
249
237
  }
250
238
  if (opener) {
251
- if (notOpeners.has(nextLine) || (opener.leaf && !closerFollows(state, nextLine, endLine, colons)))
239
+ if (notOpeners.has(nextLine))
252
240
  continue;
253
241
  const least = Math.min(colons, inner.length > 0 ? inner[inner.length - 1].least : colons);
254
242
  inner.push({ colons, indent: state.sCount[nextLine], least });
@@ -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
  /**
@@ -32,6 +32,7 @@ type Token = {
32
32
  unclosed?: boolean;
33
33
  skippedCloser?: number;
34
34
  legacyCloser?: number;
35
+ unverified?: boolean;
35
36
  } | null;
36
37
  };
37
38
  /**
@@ -40,9 +41,5 @@ type Token = {
40
41
  * missing brace or an unterminated quote loses attributes silently.
41
42
  */
42
43
  export declare function attributeProblem(block: string): string | undefined;
43
- /**
44
- * Diagnostics for a token stream. `lines` is the parsed text split at `\n`;
45
- * `lineOf` maps a 0-based line of it to a 1-based source line.
46
- */
47
44
  export declare function collectDiagnostics(tokens: Token[], lines: string[], lineOf: (line0: number) => number | undefined): ParseDiagnostic[];
48
45
  export {};
@@ -8,7 +8,7 @@
8
8
  * here: the parser passes any leaf through as a node of that type, and only a
9
9
  * renderer knows whether it can draw it (render-html reports the ones it can't).
10
10
  */
11
- import { GENERIC_CONTAINER, KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
11
+ import { BLOCK_LEAF_DIRECTIVES, GENERIC_CONTAINER } from "./plugin-containers.js";
12
12
  import { PRECEDES_TEXT } from "./plugin-inline-directives.js";
13
13
  /**
14
14
  * Whether a source line closes a container opened with `colons` colons. The
@@ -24,10 +24,54 @@ 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). */
30
- const BLOCK_DIRECTIVES = new Set([...KNOWN_BLOCK_DIRECTIVES, "avatar", "child-links", "link-card", "openapi", "autodoc"]);
74
+ const BLOCK_DIRECTIVES = new Set(BLOCK_LEAF_DIRECTIVES);
31
75
  /** Inline tokens markdown-it-attrs can give attributes to that the parser has no field for (links and images keep theirs). */
32
76
  const ATTRLESS_INLINE = { strong_open: "bold text", em_open: "italic text", s_open: "struck-through text", code_inline: "inline code" };
33
77
  /**
@@ -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;
@@ -74,7 +140,14 @@ export function collectDiagnostics(tokens, lines, lineOf) {
74
140
  const closed = closesContainer(lines[tok.map[1]], colons);
75
141
  const skipped = tok.meta?.skippedCloser;
76
142
  const legacy = tok.meta?.legacyCloser;
77
- if (!closed && legacy !== undefined && !generic) {
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) {
78
151
  // Before the nesting stack, the closer on that line ended this block and the ones inside it
79
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)`);
80
153
  }
@@ -89,6 +162,9 @@ export function collectDiagnostics(tokens, lines, lineOf) {
89
162
  else if (generic) {
90
163
  add(tok.map[0], "unknown-container", `:::${name} is not a known directive — it is shown as a plain box`);
91
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
+ }
92
168
  if (closed) {
93
169
  // The closer's end-tag label: it closes this container whatever it says, so a wrong
94
170
  // name means the author thinks it closes another one
@@ -103,6 +179,8 @@ export function collectDiagnostics(tokens, lines, lineOf) {
103
179
  if (problem)
104
180
  add(tok.map[0], "bad-attributes", `the attributes of :::${name} ${problem} — they are ignored`);
105
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 });
106
184
  }
107
185
  // A `:::` line no open block took: every block it could close is already closed
108
186
  if (tok.type === "inline" && tok.map) {
@@ -136,13 +214,13 @@ export function collectDiagnostics(tokens, lines, lineOf) {
136
214
  // Attribute blocks markdown-it-attrs read and the tree has nowhere to keep
137
215
  if (tok.type === "list_item_open" && tok.map && tok.attrs?.length) {
138
216
  // parse.ts keepListItemBraces puts trailing braces back as text, or on a heading; what is left had nowhere to go
139
- 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`);
140
218
  }
141
219
  // A table row that is only an attribute block, where the parser could not move it onto the
142
220
  // table (not the last row, or text follows the table with no blank line): an empty row
143
221
  if (tok.type === "tr_open" && tok.map) {
144
222
  const cells = [];
145
- 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++)
146
224
  cells.push(list[k]);
147
225
  const inlines = cells.filter((c) => c.type === "inline");
148
226
  if (cells[0]?.attrs?.length && /^\{[^{}]*\}$/.test((inlines[0]?.content ?? "").trim()) && inlines.slice(1).every((c) => c.content.trim() === "")) {
@@ -153,11 +231,11 @@ export function collectDiagnostics(tokens, lines, lineOf) {
153
231
  // Only real attribute names: markdown-it-attrs also reads a leftover include (`{* src.py hl[4] *}`, FastAPI) as keys
154
232
  const own = tok.attrs?.filter(([k]) => k !== "start") ?? [];
155
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))) {
156
- 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`);
157
235
  }
158
236
  // An attribute block alone on its line after a blank line: an empty paragraph that carries it
159
237
  if (tok.type === "paragraph_open" && tok.map && tok.attrs?.length) {
160
- const inline = list[list.indexOf(tok) + 1];
238
+ const inline = list[i + 1];
161
239
  // Only what an author writes as attributes: `{#id}`, `{.class}`, `{key=…}`. markdown-it-attrs also eats a
162
240
  // leftover MDX expression (`{/* prettier-ignore */}`), which is an importer's problem, not the author's
163
241
  const looksLikeAttrs = /^\{\s*(?:[#.][^\s{}]|[\w-]+\s*=)/.test(inline?.content.trim() ?? "");
@@ -185,20 +263,58 @@ export function collectDiagnostics(tokens, lines, lineOf) {
185
263
  }
186
264
  }
187
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;
188
270
  for (const child of tok.children) {
189
- 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)
190
276
  continue;
191
277
  for (const m of child.content.matchAll(/:([a-z]+)\{/g)) {
192
278
  if (m.index > 0 && PRECEDES_TEXT.test(child.content[m.index - 1]))
193
279
  continue;
194
- if (KNOWN_INLINE_DIRECTIVES.has(m[1]))
195
- 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
+ }
196
287
  }
197
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
+ }
302
+ }
303
+ }
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
+ }
198
314
  }
199
315
  // A heading's attribute block that markdown-it-attrs could not read stays in its text
200
316
  if (tok.type === "heading_open" && tok.map) {
201
- const inline = list[list.indexOf(tok) + 1];
317
+ const inline = list[i + 1];
202
318
  // markdown-it-attrs takes a block it reads out of the text tokens (not out of `content`)
203
319
  const last = [...(inline?.children ?? [])].reverse().find((c) => c.type === "text");
204
320
  const tail = /\{\s*(?:[#.][^{}]*|[\w-]+\s*=[^{}]*)\}?\s*$/.exec(last?.content ?? "");
package/dist/index.d.ts CHANGED
@@ -8,12 +8,13 @@ export { transformCodeRegions, transformProseLines, transformProseSegments, fenc
8
8
  export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcessedFence, stripInlineMinjaMarkers, stripFenceMinjaMarkers } from "./minja-mask.js";
9
9
  export type { ParseResult, ParseOptions } from "./parse.js";
10
10
  export { KNOWN_INLINE_DIRECTIVES, type ParseDiagnostic } from "./diagnostics.js";
11
- export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
11
+ export { KNOWN_BLOCK_DIRECTIVES, LEAF_ONLY_DIRECTIVES, BLOCK_LEAF_DIRECTIVES } from "./plugin-containers.js";
12
12
  export { buildNavFromDirectory } from "./nav.js";
13
13
  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
@@ -11,9 +11,10 @@ export { transformCodeRegions, transformProseLines, transformProseSegments, fenc
11
11
  export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcessedFence, stripInlineMinjaMarkers, stripFenceMinjaMarkers } from "./minja-mask.js";
12
12
  export { KNOWN_INLINE_DIRECTIVES } from "./diagnostics.js";
13
13
  // The `:::name` blocks the parser knows (an editor completes names from it)
14
- export { KNOWN_BLOCK_DIRECTIVES } from "./plugin-containers.js";
14
+ export { KNOWN_BLOCK_DIRECTIVES, LEAF_ONLY_DIRECTIVES, BLOCK_LEAF_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
+ }
@@ -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;
@@ -7,6 +7,19 @@
7
7
  */
8
8
  import type MarkdownIt from "markdown-it";
9
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"];
10
+ /**
11
+ * Directives with no body, written only as the leaf `::name{…}` (parse.ts mapContainerToTreeNode
12
+ * drops any children; the serializer writes `::`). `youtube` is also a container above, for the
13
+ * empty `:::youtube{…}` + `:::` older serializers wrote; `:::avatar` and `:::child-links` are not
14
+ * blocks at all. Exported for editors, so completion does not offer `:::` or a closer for them.
15
+ */
16
+ export declare const LEAF_ONLY_DIRECTIVES: readonly string[];
17
+ /**
18
+ * Every name `::name{…}` draws as a block on a line of its own: the containers (an empty one,
19
+ * `::grid-item{…}` = `:::grid-item{…}` + `:::`) and the body-less leaves. Inside a line, these
20
+ * are reported (`block-directive-inline`) and kept as text.
21
+ */
22
+ export declare const BLOCK_LEAF_DIRECTIVES: readonly string[];
10
23
  /**
11
24
  * The token name for a `:::name` block with a name no renderer knows. It becomes a node of that
12
25
  * type (parse.ts), which renderers show as a plain container and report, rather than literal text.
@@ -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
@@ -65,6 +66,19 @@ export const KNOWN_BLOCK_DIRECTIVES = [
65
66
  // set (plans/dita-variants-themes.md). A wrapper, so it takes blocks.
66
67
  "theme",
67
68
  ];
69
+ /**
70
+ * Directives with no body, written only as the leaf `::name{…}` (parse.ts mapContainerToTreeNode
71
+ * drops any children; the serializer writes `::`). `youtube` is also a container above, for the
72
+ * empty `:::youtube{…}` + `:::` older serializers wrote; `:::avatar` and `:::child-links` are not
73
+ * blocks at all. Exported for editors, so completion does not offer `:::` or a closer for them.
74
+ */
75
+ export const LEAF_ONLY_DIRECTIVES = ["youtube", "avatar", "child-links"];
76
+ /**
77
+ * Every name `::name{…}` draws as a block on a line of its own: the containers (an empty one,
78
+ * `::grid-item{…}` = `:::grid-item{…}` + `:::`) and the body-less leaves. Inside a line, these
79
+ * are reported (`block-directive-inline`) and kept as text.
80
+ */
81
+ export const BLOCK_LEAF_DIRECTIVES = [...KNOWN_BLOCK_DIRECTIVES, ...LEAF_ONLY_DIRECTIVES.filter((n) => !KNOWN_BLOCK_DIRECTIVES.includes(n))];
68
82
  /**
69
83
  * The token name for a `:::name` block with a name no renderer knows. It becomes a node of that
70
84
  * type (parse.ts), which renderers show as a plain container and report, rather than literal text.
@@ -72,8 +86,7 @@ export const KNOWN_BLOCK_DIRECTIVES = [
72
86
  export const GENERIC_CONTAINER = "dogsbay_generic";
73
87
  export function containersPlugin(md) {
74
88
  for (const name of KNOWN_BLOCK_DIRECTIVES) {
75
- // `:::youtube{#id}` has no body, and is often written with no closer
76
- registerOpener(name, { leaf: name === "youtube" });
89
+ registerOpener(name);
77
90
  containerRule(md, name, {
78
91
  validate: (params) => {
79
92
  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.127",
3
+ "version": "0.2.0-beta.129",
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.127",
43
- "@dogsbay/serialize-core": "0.2.0-beta.127",
44
- "@dogsbay/types": "0.2.0-beta.127"
42
+ "pele": "1.6.0",
43
+ "@dogsbay/minja": "0.2.0-beta.129",
44
+ "@dogsbay/serialize-core": "0.2.0-beta.129",
45
+ "@dogsbay/types": "0.2.0-beta.129"
45
46
  },
46
47
  "license": "MIT",
47
48
  "repository": {