@dogsbay/format-dogsbay-md 0.2.0-beta.136 → 0.2.0-beta.138

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.
@@ -192,9 +192,19 @@ export function literalCode(value) {
192
192
  }
193
193
  const list = (v) => typeof v === "string" && v.trim() ? v.split(",").map((s) => s.trim()) : undefined;
194
194
  function tableNode(rows, props, file) {
195
+ // A page never shows an empty table (docs/data-files.md): a file with no entries is reported.
196
+ if (rows.length === 0)
197
+ throw new DirectiveError(`${file} has no entries to show`);
195
198
  const columns = list(props.columns) ?? [...new Set(rows.flatMap((r) => Object.keys(r)))];
196
199
  if (columns.length === 0)
197
200
  throw new DirectiveError(`${file} has no columns to show`);
201
+ // A column no row has is a typo, not an optional field: it showed as an empty column.
202
+ const known = [...new Set(rows.flatMap((r) => Object.keys(r)))];
203
+ const missing = columns.filter((c) => !known.includes(c));
204
+ if (missing.length > 0) {
205
+ const has = known.length === 0 ? "it has no fields" : `it has ${known.slice(0, 12).join(", ")}${known.length > 12 ? `, … ${known.length - 12} more` : ""}`;
206
+ throw new DirectiveError(`${file} has no ${missing.map((c) => `"${c}"`).join(", ")} column (${has})`);
207
+ }
198
208
  const headers = list(props.headers) ?? columns;
199
209
  if (headers.length !== columns.length) {
200
210
  throw new DirectiveError(`headers names ${headers.length} column(s), columns ${columns.length}`);
@@ -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" | "leaf-as-block" | "nesting-unchecked";
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" | "unknown-prop";
6
6
  message: string;
7
7
  /**
8
8
  * The directive's name, without colons, on `unknown-container` and `unknown-inline`: a host that
@@ -9,7 +9,9 @@
9
9
  * renderer knows whether it can draw it (render-html reports the ones it can't).
10
10
  */
11
11
  import { BLOCK_LEAF_DIRECTIVES, GENERIC_CONTAINER } from "./plugin-containers.js";
12
- import { PRECEDES_TEXT } from "./plugin-inline-directives.js";
12
+ import { LABEL_PROP } from "./plugin-inline-directives.js";
13
+ import { unknownProps } from "./directive-props.js";
14
+ import * as yaml from "js-yaml";
13
15
  /**
14
16
  * Whether a source line closes a container opened with `colons` colons. The
15
17
  * line may sit in a blockquote or a list item: its `>` markers and
@@ -119,6 +121,22 @@ const clip = (line) => {
119
121
  const t = (line ?? "").trim();
120
122
  return t.length > 60 ? `${t.slice(0, 59)}…` : t;
121
123
  };
124
+ /** The keys of a known container's parsed attrs (`tok.meta.attrs`), the lifted YAML block's included. */
125
+ function propKeysOfAttrs(props) {
126
+ const keys = Object.keys(props).filter((k) => k !== "__yaml__");
127
+ const encoded = props.__yaml__;
128
+ if (typeof encoded === "string") {
129
+ try {
130
+ const loaded = yaml.load(decodeURIComponent(encoded));
131
+ if (loaded && typeof loaded === "object" && !Array.isArray(loaded))
132
+ keys.push(...Object.keys(loaded));
133
+ }
134
+ catch {
135
+ /* not YAML: nothing to check */
136
+ }
137
+ }
138
+ return keys;
139
+ }
122
140
  export function collectDiagnostics(tokens, lines, lineOf) {
123
141
  const out = [];
124
142
  const add = (line0, code, message, name) => {
@@ -167,6 +185,16 @@ export function collectDiagnostics(tokens, lines, lineOf) {
167
185
  else if (generic) {
168
186
  add(tok.map[0], "unknown-container", `:::${name} is not a known directive — it is shown as a plain box`, name);
169
187
  }
188
+ else {
189
+ // A prop no renderer of this directive reads is carried, never drawn: say so (a typo
190
+ // in a `{…}` or `---` block was silent). Keys from both forms; the YAML block arrives
191
+ // lifted into `__yaml__` by preprocessYamlBodies.
192
+ // markdown-it-attrs put the opener's `{…}` on the token's attrs; the lifted YAML block is `__yaml__`
193
+ const pairs = (tok.attrs ?? []);
194
+ for (const key of unknownProps(name, propKeysOfAttrs(Object.fromEntries(pairs)))) {
195
+ add(tok.map[0], "unknown-prop", `${key} is not a prop of :::${name} — it is kept but not drawn`, name);
196
+ }
197
+ }
170
198
  if (tok.meta?.unverified) {
171
199
  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`);
172
200
  }
@@ -195,24 +223,48 @@ export function collectDiagnostics(tokens, lines, lineOf) {
195
223
  }
196
224
  });
197
225
  }
198
- if (tok.type === "inline" && tok.map && tok.children) {
226
+ // A table cell's inline token has no line map: its row's line stands in
227
+ if (tok.type === "inline" && (tok.map || row !== undefined) && tok.children) {
228
+ const base = tok.map ? tok.map[0] : row;
199
229
  const text = tok.content ?? "";
200
230
  let from = 0;
201
231
  for (const child of tok.children) {
202
232
  if (child.type !== "dogsbay_inline_directive_open" && child.type !== "dogsbay_leaf_directive")
203
233
  continue;
234
+ // `:kbd{…}` with no `[label]` and no `value=`: nothing to show, so it stays as written
235
+ if (child.type === "dogsbay_inline_directive_open" && child.meta?.labelless && KNOWN_INLINE_DIRECTIVES.has(child.tag)) {
236
+ const key = LABEL_PROP[child.tag];
237
+ let props = {};
238
+ try {
239
+ props = JSON.parse(child.info || "{}").props ?? {};
240
+ }
241
+ catch {
242
+ /* no attrs */
243
+ }
244
+ if (key && typeof props[key] !== "string") {
245
+ const form = `:${child.tag}{`;
246
+ const at = text.indexOf(form, from);
247
+ from = at >= 0 ? at + form.length : from;
248
+ const offset = at >= 0 ? (text.slice(0, at).match(/\n/g) ?? []).length : 0;
249
+ add(base + offset, "missing-label", `:${child.tag}{…} has no label — it is shown as text; write :${child.tag}[…]{…} or :${child.tag}{${key}="…"}`);
250
+ }
251
+ continue;
252
+ }
204
253
  if (KNOWN_INLINE_DIRECTIVES.has(child.tag))
205
254
  continue;
255
+ // an unknown `:name{…}` (a CSS `:root{…}` in prose) is kept as written: nothing to report
256
+ if (child.type === "dogsbay_inline_directive_open" && child.meta?.labelless)
257
+ continue;
206
258
  const form = child.type === "dogsbay_leaf_directive" ? `::${child.tag}` : `:${child.tag}[`;
207
259
  const at = text.indexOf(form, from);
208
260
  from = at >= 0 ? at + form.length : from;
209
261
  const offset = at >= 0 ? (text.slice(0, at).match(/\n/g) ?? []).length : 0;
210
262
  const shown = child.type === "dogsbay_leaf_directive" ? `::${child.tag}` : `:${child.tag}[…]`;
211
263
  if (child.type === "dogsbay_leaf_directive" && BLOCK_DIRECTIVES.has(child.tag)) {
212
- add(tok.map[0] + offset, "block-directive-inline", `${shown} is a block directive — inside a line of text it is shown as text; put it on a line of its own`);
264
+ add(base + offset, "block-directive-inline", `${shown} is a block directive — inside a line of text it is shown as text; put it on a line of its own`);
213
265
  }
214
266
  else {
215
- add(tok.map[0] + offset, "unknown-inline", `${shown} is not a known inline directive — it is shown as text`, child.tag);
267
+ add(base + offset, "unknown-inline", `${shown} is not a known inline directive — it is shown as text`, child.tag);
216
268
  }
217
269
  }
218
270
  }
@@ -263,60 +315,24 @@ export function collectDiagnostics(tokens, lines, lineOf) {
263
315
  parsed = JSON.parse(child.info || "{}");
264
316
  }
265
317
  catch { /* not ours */ }
266
- if (parsed.id || parsed.classes?.length || Object.keys(parsed.props ?? {}).length) {
267
- add(tok.map[0], "dropped-attributes", `the attribute block on :${child.tag}[…] is dropped — of the inline directives only :badge and :button take attributes`);
268
- }
269
- }
270
- // `:kbd{…}` with no `[label]` is not a directive: the label is what it shows
271
- const missing = (name) => `:${name}{…} has no [label] — it is shown as text; write :${name}[…]{…}`;
272
- const inText = new Set();
273
- // An autolink's text is its URL, not prose
274
- let autolink = 0;
275
- for (const child of tok.children) {
276
- if (child.type === "link_open" && (child.markup === "autolink" || child.markup === "linkify"))
277
- autolink++;
278
- if (child.type === "link_close" && autolink > 0)
279
- autolink--;
280
- if (child.type !== "text" || autolink > 0)
281
- continue;
282
- for (const m of child.content.matchAll(/:([a-z]+)\{/g)) {
283
- if (m.index > 0 && PRECEDES_TEXT.test(child.content[m.index - 1]))
318
+ const props = { ...(parsed.props ?? {}) };
319
+ // the label-less form's naming prop IS the label (`:kbd{value="Ctrl"}`); without it the
320
+ // directive stays as written and `missing-label` has said so
321
+ if (child.meta?.labelless) {
322
+ const key = LABEL_PROP[child.tag];
323
+ if (key && typeof props[key] !== "string")
284
324
  continue;
285
- if (KNOWN_INLINE_DIRECTIVES.has(m[1])) {
286
- inText.add(m[1]);
287
- let at = tok.map[0];
288
- while (at < tok.map[1] - 1 && !(lines[at] ?? "").includes(`:${m[1]}{`))
289
- at++;
290
- add(at, "missing-label", missing(m[1]));
291
- }
325
+ if (key)
326
+ delete props[key];
292
327
  }
293
- }
294
- // At the end of a paragraph, heading or cell, markdown-it-attrs takes the braces for the
295
- // block's own, leaving `:kbd` in the text: read the source lines for what is no longer there
296
- if (list[i - 1]?.attrs?.length) {
297
- for (let l = tok.map[0]; l < tok.map[1]; l++) {
298
- const line = blankCodeAndTags(lines[l] ?? "");
299
- for (const m of line.matchAll(/:([a-z]+)\{/g)) {
300
- if (m.index > 0 && PRECEDES_TEXT.test(line[m.index - 1]))
301
- continue;
302
- if (KNOWN_INLINE_DIRECTIVES.has(m[1]) && !inText.has(m[1]) && !line.startsWith(`:${m[1]}[`, m.index)) {
303
- inText.add(m[1]);
304
- add(l, "missing-label", missing(m[1]));
305
- }
306
- }
328
+ if (parsed.id || parsed.classes?.length || Object.keys(props).length) {
329
+ add(tok.map[0], "dropped-attributes", `the attribute block on :${child.tag}[…] is dropped — of the inline directives only :badge and :button take attributes`);
307
330
  }
308
331
  }
309
332
  }
310
333
  // A table cell's inline token has no line map: its row's has
311
334
  if (tok.type === "tr_open" && tok.map)
312
335
  row = tok.map[0];
313
- if (tok.type === "inline" && !tok.map && row !== undefined && /^t[hd]_open$/.test(list[i - 1]?.type ?? "") && list[i - 1]?.attrs?.length) {
314
- const cell = blankCodeAndTags(tok.content);
315
- const m = /:([a-z]+)\{/.exec(cell);
316
- if (m && KNOWN_INLINE_DIRECTIVES.has(m[1]) && !(m.index > 0 && PRECEDES_TEXT.test(cell[m.index - 1]))) {
317
- add(row, "missing-label", `:${m[1]}{…} has no [label] — it is shown as text; write :${m[1]}[…]{…}`);
318
- }
319
- }
320
336
  // A heading's attribute block that markdown-it-attrs could not read stays in its text
321
337
  if (tok.type === "heading_open" && tok.map) {
322
338
  const inline = list[i + 1];
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The props each known directive DRAWS, for the `unknown-prop` diagnostic (2026-10-27): a key
3
+ * a renderer does not read is carried through every round trip but drawn by nothing, and a
4
+ * typo in a `{…}` block or a `---` props block was silent. The lists are the union of what
5
+ * docs/dogsbay-md.md documents and what format-astro's and render-html's renderers read; a
6
+ * directive not listed here is not checked (api-*, autodoc, openapi, config, theme, example:
7
+ * their props are open-ended or read from a spec).
8
+ *
9
+ * Always allowed on anything: `id`, `class`, `style`, `role`, `data-*`, `aria-*` (passed
10
+ * through to the element), and `type`/`title`/`icon` on the callout names.
11
+ */
12
+ export declare const PASSTHROUGH_PROP: RegExp;
13
+ export declare const DIRECTIVE_PROPS: Readonly<Record<string, readonly string[]>>;
14
+ /** Keys of `props` that `name` does not draw; empty when the directive is not checked. */
15
+ export declare function unknownProps(name: string, props: Iterable<string>): string[];
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The props each known directive DRAWS, for the `unknown-prop` diagnostic (2026-10-27): a key
3
+ * a renderer does not read is carried through every round trip but drawn by nothing, and a
4
+ * typo in a `{…}` block or a `---` props block was silent. The lists are the union of what
5
+ * docs/dogsbay-md.md documents and what format-astro's and render-html's renderers read; a
6
+ * directive not listed here is not checked (api-*, autodoc, openapi, config, theme, example:
7
+ * their props are open-ended or read from a spec).
8
+ *
9
+ * Always allowed on anything: `id`, `class`, `style`, `role`, `data-*`, `aria-*` (passed
10
+ * through to the element), and `type`/`title`/`icon` on the callout names.
11
+ */
12
+ export const PASSTHROUGH_PROP = /^(?:id|class|style|role|data-[\w-]+|aria-[\w-]+)$/;
13
+ const CALLOUT = ["type", "title", "icon", "collapsed", "collapsible", "open", "tone", "variant", "href"];
14
+ const CALLOUT_NAMES = ["callout", "note", "tip", "info", "important", "warning", "caution", "danger", "success", "hint", "attention", "error", "failure", "bug", "question", "abstract", "quote"];
15
+ export const DIRECTIVE_PROPS = {
16
+ ...Object.fromEntries(CALLOUT_NAMES.map((n) => [n, CALLOUT])),
17
+ details: ["title", "summary", "icon", "open", "variant", "type", "sheet"],
18
+ tabs: ["variant", "sync", "syncKey", "group", "title", "value", "label"],
19
+ tab: ["title", "label", "icon", "value"],
20
+ steps: ["title"],
21
+ step: ["title"],
22
+ cards: ["cols", "tone", "variant"],
23
+ card: ["title", "href", "icon", "description", "tone", "variant", "image", "alt"],
24
+ "link-card": ["href", "title", "description", "icon"],
25
+ grid: ["cols", "gap"],
26
+ "grid-item": ["span", "rowSpan", "label", "tone"],
27
+ figure: ["caption", "src", "alt"],
28
+ files: [],
29
+ button: ["href", "variant", "label", "size"],
30
+ youtube: ["id", "title", "poster"],
31
+ avatar: ["src", "alt", "fallback", "source", "size"],
32
+ accordion: ["type", "defaultValue", "variant"],
33
+ "accordion-item": ["title", "label", "value", "open"],
34
+ math: [],
35
+ "data-table": ["src", "columns", "caption", "sortable", "filter", "paginate"],
36
+ "data-cards": ["src", "cols", "tone"],
37
+ "child-links": ["depth", "title"],
38
+ };
39
+ /** Keys of `props` that `name` does not draw; empty when the directive is not checked. */
40
+ export function unknownProps(name, props) {
41
+ const known = DIRECTIVE_PROPS[name];
42
+ if (!known)
43
+ return [];
44
+ const set = new Set(known);
45
+ const out = [];
46
+ for (const key of props)
47
+ if (!set.has(key) && !PASSTHROUGH_PROP.test(key) && key !== "__yaml__")
48
+ out.push(key);
49
+ return out;
50
+ }
@@ -16,3 +16,5 @@ export interface DirectiveRenderInput {
16
16
  * the component, option threshold, and prop complexity.
17
17
  */
18
18
  export declare function renderBlockDirective(input: DirectiveRenderInput, ctx: ResolvedOptions): string;
19
+ /** A body whose first line is `---` gets a blank line before it, so the parser reads a rule, not props. */
20
+ export declare function guardRule(body: string): string;
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { renderAttrs, splitProps } from "./attributes.js";
13
13
  import { pickDirectiveFence } from "./escape.js";
14
- import { renderYaml } from "./yaml.js";
14
+ import { renderYamlReadable } from "./yaml.js";
15
15
  /**
16
16
  * Render a block directive. Chooses attrs-form vs yaml-body form based on
17
17
  * the component, option threshold, and prop complexity.
@@ -20,16 +20,20 @@ export function renderBlockDirective(input, ctx) {
20
20
  const { name, props, excludeProps = [], body = "" } = input;
21
21
  const yamlEligible = input.yamlEligible ?? ctx.yamlComponents.has(name);
22
22
  const attrs = splitProps(props, excludeProps);
23
- const useYaml = yamlEligible && shouldUseYaml(attrs, ctx);
23
+ // The parser reads a `---` props block on every directive (2026-10-27), so a nested value —
24
+ // which the inline form cannot carry and used to be dropped — takes the YAML form anywhere.
25
+ const useYaml = (yamlEligible && shouldUseYaml(attrs, ctx)) || needsYamlForm(attrs);
24
26
  const fence = pickDirectiveFence(useYaml ? body + "\n---" : body);
25
27
  if (!useYaml) {
26
28
  const attrStr = renderAttrs(attrs);
27
29
  const opener = attrStr ? `${fence}${name}${attrStr}` : `${fence}${name}`;
28
- return `${opener}\n${body}\n${fence}`;
30
+ // A body that opens with a horizontal rule would read as a `---` props block on the way
31
+ // back (the parser reads one on every directive): a blank line keeps it a rule.
32
+ return `${opener}\n${guardRule(body)}\n${fence}`;
29
33
  }
30
34
  // YAML body form — emit all props (including id, classes) as yaml keys
31
35
  const yamlData = attrsToYaml(attrs);
32
- const yaml = renderYaml(yamlData);
36
+ const yaml = renderYamlReadable(yamlData);
33
37
  const header = yaml.length > 0 ? `---\n${yaml}\n---` : "";
34
38
  if (!body) {
35
39
  return `${fence}${name}\n${header}\n${fence}`;
@@ -50,6 +54,14 @@ export function renderBlockDirective(input, ctx) {
50
54
  * form has no syntax for those — without YAML they get
51
55
  * dropped by `renderAttrs`).
52
56
  */
57
+ /** A value the inline form cannot carry: nested, or a string with a newline (the inline form flattens it to spaces). */
58
+ function needsYamlForm(attrs) {
59
+ return Object.values(attrs.props ?? {}).some((v) => (v !== null && typeof v === "object") || (typeof v === "string" && v.includes("\n")));
60
+ }
61
+ /** A body whose first line is `---` gets a blank line before it, so the parser reads a rule, not props. */
62
+ export function guardRule(body) {
63
+ return /^---[ \t]*(\r?\n|$)/.test(body) ? `\n${body}` : body;
64
+ }
53
65
  function shouldUseYaml(attrs, ctx) {
54
66
  const propCount = (attrs.id ? 1 : 0) +
55
67
  (attrs.classes?.length ?? 0) +
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcess
9
9
  export type { ParseResult, ParseOptions } from "./parse.js";
10
10
  export { KNOWN_INLINE_DIRECTIVES, type ParseDiagnostic } from "./diagnostics.js";
11
11
  export { KNOWN_BLOCK_DIRECTIVES, LEAF_ONLY_DIRECTIVES, BLOCK_LEAF_DIRECTIVES } from "./plugin-containers.js";
12
+ export { DIRECTIVE_PROPS, PASSTHROUGH_PROP, unknownProps } from "./directive-props.js";
12
13
  export { buildNavFromDirectory } from "./nav.js";
13
14
  export type { BuildNavOptions } from "./nav.js";
14
15
  export { loadNavFile } from "./nav-file.js";
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcess
12
12
  export { KNOWN_INLINE_DIRECTIVES } from "./diagnostics.js";
13
13
  // The `:::name` blocks the parser knows (an editor completes names from it)
14
14
  export { KNOWN_BLOCK_DIRECTIVES, LEAF_ONLY_DIRECTIVES, BLOCK_LEAF_DIRECTIVES } from "./plugin-containers.js";
15
+ export { DIRECTIVE_PROPS, PASSTHROUGH_PROP, unknownProps } from "./directive-props.js";
15
16
  export { buildNavFromDirectory } from "./nav.js";
16
17
  export { loadNavFile } from "./nav-file.js";
17
18
  export { renderDiagrams } from "./renderers/diagrams.js";
package/dist/inline.js CHANGED
@@ -194,7 +194,7 @@ function renderInline(node) {
194
194
  case "highlight":
195
195
  return `==${inlineToDogsbayMd(node.children)}==`;
196
196
  case "kbd":
197
- return `:kbd[${node.keys.join("+")}]`;
197
+ return `:kbd[${node.keys.join("+").replace(/[\\[\]]/g, "\\$&")}]`;
198
198
  case "badge":
199
199
  return `:badge[${node.text.replace(/[\\[\]]/g, "\\$&")}]${renderInlineAttrs(node.variant !== undefined ? { variant: node.variant, ...node.attrs } : node.attrs)}`;
200
200
  case "icon":
@@ -307,9 +307,9 @@ function renderIcon(node) {
307
307
  }
308
308
  }
309
309
  if (extraAttrs.length === 0) {
310
- return `:icon[${fullName}]`;
310
+ return `:icon[${fullName.replace(/[\\[\]]/g, "\\$&")}]`;
311
311
  }
312
- return `:icon[${fullName}]{${extraAttrs.join(" ")}}`;
312
+ return `:icon[${fullName.replace(/[\\[\]]/g, "\\$&")}]{${extraAttrs.join(" ")}}`;
313
313
  }
314
314
  /**
315
315
  * Escape markdown-active characters in inline text.
@@ -327,6 +327,9 @@ function escapeInlineTextSegment(text) {
327
327
  return text
328
328
  .replace(/\\/g, "\\\\")
329
329
  .replace(/([*`\[\]])/g, "\\$1")
330
+ // `:name{` would read as a label-less inline directive (2026-10-27): the brace is escaped the
331
+ // way `[` is for `:name[`. Text is literal; a round trip must never make a directive of it.
332
+ .replace(/(^|[^\w:]):([A-Za-z][\w-]*)\{/g, "$1:$2\\{")
330
333
  // Text that looks like a tag or autolink (`<draft-comment>`) would render as HTML
331
334
  .replace(/<(?=[A-Za-z/!?])/g, "\\<")
332
335
  // One pass: escaping `_` at a word start and then at a word end double-escaped `(_)`
@@ -16,6 +16,12 @@ export interface ParsedAttrs {
16
16
  props: Record<string, string | boolean>;
17
17
  }
18
18
  export declare function parseAttrs(inner: string): ParsedAttrs;
19
+ /**
20
+ * Whether `inner` is ONLY a list of attributes, as parseAttrs reads them. parseAttrs never fails,
21
+ * so text that only looks like a block (CSS in prose, `{--x: 1}`) comes out reordered; a reader
22
+ * that may meet such text (the label-less inline form) asks this first and leaves it as text.
23
+ */
24
+ export declare function isAttrList(inner: string): boolean;
19
25
  /**
20
26
  * Merge ParsedAttrs into a props dict suitable for TreeNode.props.
21
27
  * `id` and `classes` become `id` and `class`; other props flow through.
@@ -99,6 +99,23 @@ export function parseAttrs(inner) {
99
99
  }
100
100
  return result;
101
101
  }
102
+ /** One item of an attribute list: `#id`, `.class`, `key`, `key=bare`, `key="…"`, `key='…'`. */
103
+ const ATTR_ITEM = /\s*(?:[#.][\w-]+|[A-Za-z_][\w-]*(?:=(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s"'=]+))?)(?=\s|$)/y;
104
+ /**
105
+ * Whether `inner` is ONLY a list of attributes, as parseAttrs reads them. parseAttrs never fails,
106
+ * so text that only looks like a block (CSS in prose, `{--x: 1}`) comes out reordered; a reader
107
+ * that may meet such text (the label-less inline form) asks this first and leaves it as text.
108
+ */
109
+ export function isAttrList(inner) {
110
+ let pos = 0;
111
+ ATTR_ITEM.lastIndex = 0;
112
+ while (ATTR_ITEM.lastIndex < inner.length) {
113
+ if (!ATTR_ITEM.test(inner))
114
+ return /^\s*$/.test(inner.slice(pos));
115
+ pos = ATTR_ITEM.lastIndex;
116
+ }
117
+ return true;
118
+ }
102
119
  /**
103
120
  * Merge ParsedAttrs into a props dict suitable for TreeNode.props.
104
121
  * `id` and `classes` become `id` and `class`; other props flow through.
package/dist/parse.js CHANGED
@@ -11,7 +11,9 @@ import mdAttrs from "markdown-it-attrs";
11
11
  import mdDeflist from "markdown-it-deflist";
12
12
  import mdFootnote from "markdown-it-footnote";
13
13
  import * as yaml from "js-yaml";
14
- import { inlineDirectivesPlugin } from "./plugin-inline-directives.js";
14
+ import { createFenceTracker } from "./code-regions.js";
15
+ import { expandedSize } from "./yaml.js";
16
+ import { LABEL_PROP, inlineDirectivesPlugin } from "./plugin-inline-directives.js";
15
17
  import { containersPlugin, GENERIC_CONTAINER } from "./plugin-containers.js";
16
18
  import { inlineToDogsbayMd } from "./inline.js";
17
19
  import { markPlugin } from "./plugin-mark.js";
@@ -163,6 +165,38 @@ function foldRelated(nodes) {
163
165
  * names so existing valid markdown that uses `---` as a horizontal
164
166
  * rule inside (e.g.) a `:::callout` body isn't accidentally consumed.
165
167
  */
168
+ /** An UNINDENTED container line ends the search for a YAML body's closing `---`: a block scalar's lines are indented and may hold `:::`. */
169
+ const CONTAINER_LINE = /^:{3,}/;
170
+ /** What a directive's props may expand to (a YAML alias bomb parses in a millisecond and is 9^9 elements when walked). */
171
+ const MAX_PROPS_VALUES = 20_000;
172
+ /**
173
+ * Whether `---` … `---` right after an opener is a props block for a directive outside
174
+ * YAML_BODY_COMPONENTS (2026-10-27, the Comark idea: props on ANY directive). The text must be
175
+ * YAML that is a mapping with prop-shaped keys — a body that opens with a horizontal rule and
176
+ * later has another is not that — and the closing `---` must come before any container line.
177
+ */
178
+ function isYamlPropsBody(lines, from, to) {
179
+ for (let k = from; k < to; k++)
180
+ if (CONTAINER_LINE.test(lines[k]))
181
+ return false;
182
+ const text = lines.slice(from, to).join("\n");
183
+ if (text.trim() === "")
184
+ return false;
185
+ try {
186
+ const parsed = yaml.load(text);
187
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
188
+ return false;
189
+ if (expandedSize(parsed, MAX_PROPS_VALUES) > MAX_PROPS_VALUES)
190
+ return false;
191
+ const keys = Object.keys(parsed);
192
+ // prop-shaped keys, or keys the author quoted (`"a b":`), which the serializer writes for the rest
193
+ const quoted = new Set([...text.matchAll(/^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'):/gm)].map((m) => (m[1] !== undefined ? JSON.parse(`"${m[1]}"`) : m[2])));
194
+ return keys.length > 0 && keys.every((k) => /^[A-Za-z_][\w.-]*$/.test(k) || quoted.has(k));
195
+ }
196
+ catch {
197
+ return false;
198
+ }
199
+ }
166
200
  const YAML_BODY_COMPONENTS = new Set([
167
201
  "card",
168
202
  "api-endpoint",
@@ -209,22 +243,29 @@ function preprocessYamlBodies(source) {
209
243
  const lines = source.split("\n");
210
244
  const out = [];
211
245
  const lineMap = [];
246
+ // Which lines are fenced code: a `:::card` shown inside a ```markdown example is text, and the
247
+ // lift must not read its `---` block (it rewrote a docs page's example, 2026-10-27).
248
+ const fence = createFenceTracker();
249
+ const code = lines.map((l) => fence.step(l) !== "prose");
212
250
  let i = 0;
213
251
  while (i < lines.length) {
214
252
  const line = lines[i];
215
253
  // Match an opener: 3+ colons, name, optional {existing attrs}
216
- const opener = line.match(/^(:{3,})([a-zA-Z][\w-]*)(\{[^}]*\})?\s*$/);
254
+ const opener = code[i] ? null : line.match(/^(:{3,})([a-zA-Z][\w-]*)(\{.*\})?\s*$/);
217
255
  if (opener &&
218
- YAML_BODY_COMPONENTS.has(opener[2]) &&
219
256
  i + 1 < lines.length &&
220
257
  lines[i + 1].trim() === "---") {
221
258
  // Find closing `---` line. YAML doesn't allow a bare `---` as
222
259
  // value content (it's a document separator), so the next match
223
260
  // is unambiguously the closing fence.
261
+ // Only an UNINDENTED `---` closes: a block scalar's line may be ` ---`
224
262
  let j = i + 2;
225
- while (j < lines.length && lines[j].trim() !== "---")
263
+ while (j < lines.length && !(/^---[ \t]*\r?$/.test(lines[j]) && !code[j]))
226
264
  j++;
227
- if (j < lines.length) {
265
+ // The YAML directives take the block as they always did; any other directive takes it
266
+ // only when it reads as props (isYamlPropsBody), so an `---` rule in a callout stays a rule.
267
+ const fenced = lines.slice(i + 2, j).some((_, k) => code[i + 2 + k]);
268
+ if (j < lines.length && !fenced && (YAML_BODY_COMPONENTS.has(opener[2]) || isYamlPropsBody(lines, i + 2, j))) {
228
269
  const yamlText = lines.slice(i + 2, j).join("\n");
229
270
  const encoded = encodeURIComponent(yamlText);
230
271
  const fence = opener[1];
@@ -1104,9 +1145,11 @@ function consumeContainer(ctx) {
1104
1145
  // block serializes back as written.
1105
1146
  // (A bracketed `[Title]` was already lifted into props.title above.)
1106
1147
  const title = infoRest.startsWith("[") ? "" : infoRest.replace(/\{[\s\S]*\}\s*$/, "").trim();
1148
+ const unknownProps = attrs ? attrsToProps(attrs) : {};
1149
+ mergeYamlProps(unknownProps);
1107
1150
  return {
1108
1151
  type: "container",
1109
- props: attrs ? attrsToProps(attrs) : {},
1152
+ props: unknownProps,
1110
1153
  children,
1111
1154
  meta: { container: true, name, ...(title ? { title } : {}), ...(closer ? { closer } : {}) },
1112
1155
  };
@@ -1118,33 +1161,37 @@ function consumeContainer(ctx) {
1118
1161
  * Convert a container + children into the appropriate TreeNode(s).
1119
1162
  * Handles callout type aliases, tabs definition-list recovery, cards list form, etc.
1120
1163
  */
1121
- function mapContainerToTreeNode(name, attrs, children) {
1122
- const props = attrs ? attrsToProps(attrs) : {};
1123
- // YAML body lift-up: `preprocessYamlBodies` (run during
1124
- // dogsbayMdToTree) stashed the directive's YAML body as a
1125
- // URL-encoded `__yaml__` prop. Decode + parse + merge into props.
1126
- // Anything malformed is silently dropped — better to lose the YAML
1127
- // than to fail the whole parse on a typo.
1128
- if (typeof props.__yaml__ === "string") {
1129
- const encoded = props.__yaml__;
1130
- delete props.__yaml__;
1131
- try {
1132
- const yamlText = decodeURIComponent(encoded);
1133
- const parsed = yaml.load(yamlText);
1134
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1135
- // YAML wins on conflict — the serializer emits ALL props in
1136
- // the YAML body, so any inline attrs in addition came from a
1137
- // hand-edit and are likely meant as overrides; but conflicts
1138
- // here are fringe. Going with YAML for round-trip stability.
1139
- for (const [k, v] of Object.entries(parsed)) {
1140
- props[k] = v;
1141
- }
1164
+ /**
1165
+ * YAML body lift-up: `preprocessYamlBodies` (run during dogsbayMdToTree) stashed a directive's
1166
+ * YAML body as a URL-encoded `__yaml__` prop. Decode + parse + merge into props. Anything
1167
+ * malformed is silently dropped — better to lose the YAML than to fail the whole parse on a
1168
+ * typo. Every directive, known or not, reads it (2026-10-27).
1169
+ */
1170
+ function mergeYamlProps(props) {
1171
+ if (typeof props.__yaml__ !== "string")
1172
+ return;
1173
+ const encoded = props.__yaml__;
1174
+ delete props.__yaml__;
1175
+ try {
1176
+ const yamlText = decodeURIComponent(encoded);
1177
+ const parsed = yaml.load(yamlText);
1178
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && expandedSize(parsed, MAX_PROPS_VALUES) <= MAX_PROPS_VALUES) {
1179
+ // YAML wins on conflict — the serializer emits ALL props in
1180
+ // the YAML body, so any inline attrs in addition came from a
1181
+ // hand-edit and are likely meant as overrides; but conflicts
1182
+ // here are fringe. Going with YAML for round-trip stability.
1183
+ for (const [k, v] of Object.entries(parsed)) {
1184
+ props[k] = v;
1142
1185
  }
1143
1186
  }
1144
- catch {
1145
- // Malformed encoding or YAML — leave props as-is sans __yaml__.
1146
- }
1147
1187
  }
1188
+ catch {
1189
+ // Malformed encoding or YAML — leave props as-is sans __yaml__.
1190
+ }
1191
+ }
1192
+ function mapContainerToTreeNode(name, attrs, children) {
1193
+ const props = attrs ? attrsToProps(attrs) : {};
1194
+ mergeYamlProps(props);
1148
1195
  // Callout type aliases — :::note, :::tip, etc. map to callout with type
1149
1196
  const CALLOUT_TYPES = new Set([
1150
1197
  "note",
@@ -1748,7 +1795,14 @@ function tokensToInline(tokens, inheritedFmt = []) {
1748
1795
  }
1749
1796
  const labelInline = tokensToInline(labelTokens);
1750
1797
  const labelText = inlineText(labelInline);
1751
- const node = inlineDirectiveToNode(name, labelText, labelInline, attrs);
1798
+ const meta = tok.meta;
1799
+ const labelless = meta?.labelless === true;
1800
+ let node = meta?.literal && meta.source ? { type: "text", text: meta.source } : inlineDirectiveToNode(name, labelText, labelInline, attrs, labelless);
1801
+ // A label-less directive that makes nothing (unknown, or a known one without its naming
1802
+ // prop) stays exactly as written, as TEXT: its attrs may carry a tag, html-inline is written
1803
+ // raw by every consumer, and re-rendering the attrs reordered them
1804
+ if (labelless && meta?.source && ((node?.type === "html-inline" && node.html.startsWith(`:${name}[]`)) || (node?.type === "text" && node.text.startsWith(`:${name}{`))))
1805
+ node = { type: "text", text: meta.source };
1752
1806
  if (node)
1753
1807
  result.push(node);
1754
1808
  break;
@@ -1857,7 +1911,24 @@ function inlineAttrsOf(attrs, props) {
1857
1911
  out[k] = v === true ? "" : String(v);
1858
1912
  return Object.keys(out).length ? out : undefined;
1859
1913
  }
1860
- function inlineDirectiveToNode(name, labelText, labelInline, attrs) {
1914
+ function inlineDirectiveToNode(name, labelText, labelInline, attrs, labelless = false) {
1915
+ // Label-less form: the naming prop is the label, and leaves the attrs. Without that prop a
1916
+ // known directive has nothing to show, so it stays as written (and diagnostics say so).
1917
+ const labelKey = LABEL_PROP[name];
1918
+ if (labelless && labelKey) {
1919
+ if (attrs && typeof attrs.props[labelKey] === "string") {
1920
+ labelText = attrs.props[labelKey];
1921
+ labelInline = [{ type: "text", text: labelText }];
1922
+ const { [labelKey]: _dropped, ...props } = attrs.props;
1923
+ attrs = { ...attrs, props };
1924
+ }
1925
+ else {
1926
+ // Text, not html-inline: the attrs may hold `<img onerror=…>`, and a consumer writes
1927
+ // html-inline raw. The serializer escapes the brace, so it stays text on the way back.
1928
+ const block = attrs ? renderAttrs({ id: attrs.id, classes: attrs.classes, props: attrs.props }).trim() : "";
1929
+ return { type: "text", text: `:${name}${block || "{}"}` };
1930
+ }
1931
+ }
1861
1932
  switch (name) {
1862
1933
  case "kbd": {
1863
1934
  const keys = labelText.split(/\s*\+\s*/).filter(Boolean);
@@ -4,6 +4,12 @@
4
4
  * Recognizes:
5
5
  * :name[text]{attrs} — text directive (label can contain inline markdown)
6
6
  * :name[text] — text directive, no attrs
7
+ * :name{attrs} — text directive with no label (Comark's "properties only" form,
8
+ * 2026-10-27): a known directive takes its label from the prop that
9
+ * names it (`:kbd{value="Ctrl"}`, `:icon{name="lucide:star"}`); an
10
+ * unknown one is kept as written. Before, the rule declined it and
11
+ * markdown-it-attrs then read a trailing `{…}` as the PARAGRAPH's
12
+ * attributes, so the props vanished.
7
13
  * ::name{attrs} — leaf directive (self-closing)
8
14
  * ::name — leaf directive, no attrs
9
15
  *
@@ -16,4 +22,6 @@
16
22
  import type MarkdownIt from "markdown-it";
17
23
  /** A character a directive cannot follow directly (see inlineDirectiveRule). */
18
24
  export declare const PRECEDES_TEXT: RegExp;
25
+ /** The prop that labels a known inline directive written without `[text]` (`:kbd{value="Ctrl"}`). */
26
+ export declare const LABEL_PROP: Readonly<Record<string, string>>;
19
27
  export declare function inlineDirectivesPlugin(md: MarkdownIt): void;
@@ -1,6 +1,8 @@
1
- import { attrBlockEnd, parseAttrs } from "./parse-attrs.js";
1
+ import { attrBlockEnd, isAttrList, 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
+ /** The prop that labels a known inline directive written without `[text]` (`:kbd{value="Ctrl"}`). */
5
+ export const LABEL_PROP = { kbd: "value", icon: "name", badge: "label", button: "label", mark: "text", highlight: "text", math: "latex" };
4
6
  export function inlineDirectivesPlugin(md) {
5
7
  md.inline.ruler.before("emphasis", "dogsbay_inline_directive", inlineDirectiveRule);
6
8
  }
@@ -45,13 +47,23 @@ function inlineDirectiveRule(state, silent) {
45
47
  let label = "";
46
48
  let labelStart = -1;
47
49
  let labelEnd = -1;
48
- if (!isLeaf) {
50
+ /** `:name{attrs}`: no `[label]` at all. */
51
+ let labelless = false;
52
+ if (!isLeaf && src.charCodeAt(pos) === 0x7b /* { */) {
53
+ labelless = true;
54
+ }
55
+ else if (!isLeaf) {
49
56
  // Text directive requires a [label]
50
57
  if (src.charCodeAt(pos) !== 0x5b /* [ */)
51
58
  return false;
52
59
  pos++;
53
60
  labelStart = pos;
54
- // Find matching closing bracket (ignore escapes)
61
+ // Find matching closing bracket (ignore escapes). A scan that reached the end without one
62
+ // is remembered for the parse: every later candidate on the input would scan the same tail
63
+ // (a run of `:a[` was quadratic, 80 KB in 8 s)
64
+ const noLabel = state.env.__dbNoLabelClose;
65
+ if (noLabel !== undefined && labelStart >= noLabel)
66
+ return false;
55
67
  let depth = 1;
56
68
  while (pos < src.length) {
57
69
  const c = src.charCodeAt(pos);
@@ -68,27 +80,46 @@ function inlineDirectiveRule(state, silent) {
68
80
  }
69
81
  pos++;
70
82
  }
71
- if (pos >= src.length)
83
+ if (pos >= src.length) {
84
+ state.env.__dbNoLabelClose = labelStart;
72
85
  return false;
86
+ }
73
87
  labelEnd = pos;
74
88
  label = src.slice(labelStart, labelEnd);
75
89
  pos++; // consume ]
76
90
  }
77
- // Optional { attrs }
91
+ // Optional { attrs } — required in the label-less form
78
92
  let attrs = "";
79
93
  if (src.charCodeAt(pos) === 0x7b /* { */) {
80
94
  const attrStart = pos + 1;
81
- const ap = attrBlockEnd(src, attrStart, src.length);
95
+ // An attribute block ends on its line: scanning to the end of the input made a run of `:a{ `
96
+ // quadratic (80 KB took 4 s)
97
+ const lineEnd = src.indexOf("\n", attrStart);
98
+ const max = lineEnd < 0 ? src.length : lineEnd;
99
+ // the same memo for a `{` with no `}` on its line
100
+ const env = state.env;
101
+ const known = env.__dbNoAttrClose && env.__dbNoAttrClose.max === max && attrStart >= env.__dbNoAttrClose.from;
102
+ const ap = known ? -1 : attrBlockEnd(src, attrStart, max);
82
103
  if (ap >= 0) {
83
104
  attrs = src.slice(attrStart, ap);
84
105
  pos = ap + 1;
85
106
  }
107
+ else {
108
+ if (!known)
109
+ env.__dbNoAttrClose = { max, from: attrStart };
110
+ if (labelless)
111
+ return false;
112
+ }
86
113
  }
87
114
  if (silent) {
88
115
  state.pos = pos;
89
116
  return true;
90
117
  }
91
- const parsedAttrs = parseAttrs(attrs);
118
+ // `:root{--x: 1}` in prose is CSS, not attributes: read leniently it came out reordered
119
+ // (`:root{1 --x:}`). It is still taken here, as literal text: left to the paragraph, a trailing
120
+ // `{…}` would be read as the paragraph's attributes and vanish.
121
+ const literal = labelless && !isAttrList(attrs);
122
+ const parsedAttrs = literal ? { classes: [], props: {} } : parseAttrs(attrs);
92
123
  const attrsStr = JSON.stringify(parsedAttrs);
93
124
  if (isLeaf) {
94
125
  const t = state.push("dogsbay_leaf_directive", "", 0);
@@ -99,14 +130,19 @@ function inlineDirectiveRule(state, silent) {
99
130
  const openT = state.push("dogsbay_inline_directive_open", "", 1);
100
131
  openT.tag = name;
101
132
  openT.info = attrsStr;
102
- // Parse label as inline content (recursive)
103
- const oldPos = state.pos;
104
- const oldPosMax = state.posMax;
105
- state.pos = labelStart;
106
- state.posMax = labelEnd;
107
- state.md.inline.tokenize(state);
108
- state.pos = oldPos;
109
- state.posMax = oldPosMax;
133
+ // the source as written: an unknown label-less directive stays these bytes, not a re-rendering
134
+ if (labelless)
135
+ openT.meta = { labelless: true, literal, source: src.slice(start, pos) };
136
+ if (!labelless) {
137
+ // Parse label as inline content (recursive)
138
+ const oldPos = state.pos;
139
+ const oldPosMax = state.posMax;
140
+ state.pos = labelStart;
141
+ state.posMax = labelEnd;
142
+ state.md.inline.tokenize(state);
143
+ state.pos = oldPos;
144
+ state.posMax = oldPosMax;
145
+ }
110
146
  const closeT = state.push("dogsbay_inline_directive_close", "", -1);
111
147
  closeT.tag = name;
112
148
  }
package/dist/project.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * browser imports (`@dogsbay/format-vitepress/migrate`) return them, so a migration in the
6
6
  * browser is the one `convert` does.
7
7
  */
8
+ import type { SerializeOptions } from "./types.js";
8
9
  import type { ExportPage, NavItem } from "@dogsbay/types";
9
10
  /** Media files worth carrying into `_assets` from a media-only mount. */
10
11
  export declare const MEDIA_EXTS: Set<string>;
@@ -29,7 +30,7 @@ export declare function carriedFrontmatter(fm: Record<string, unknown> | undefin
29
30
  /** Each page as `<slug>.md` and the nav as `nav.json`, relative to the content root. */
30
31
  /** The file a page is written to, relative to the content root (`dogsbayMdPageFiles`). */
31
32
  export declare function dogsbayMdPagePath(page: Pick<ExportPage, "slug">): string;
32
- export declare function dogsbayMdPageFiles(pages: ExportPage[], nav: NavItem[]): Map<string, string>;
33
+ export declare function dogsbayMdPageFiles(pages: ExportPage[], nav: NavItem[], serialize?: SerializeOptions): Map<string, string>;
33
34
  /** An importer's asset directory, as `ImportResult.assetMounts` declares it, relative to the corpus. */
34
35
  export interface AssetMount {
35
36
  dir: string;
package/dist/project.js CHANGED
@@ -62,7 +62,7 @@ export function carriedFrontmatter(fm) {
62
62
  export function dogsbayMdPagePath(page) {
63
63
  return `${page.slug || "index"}.md`;
64
64
  }
65
- export function dogsbayMdPageFiles(pages, nav) {
65
+ export function dogsbayMdPageFiles(pages, nav, serialize) {
66
66
  const files = new Map();
67
67
  for (const page of pages) {
68
68
  // A template partial keeps its `params:` and the template flag, so the
@@ -73,7 +73,7 @@ export function dogsbayMdPageFiles(pages, nav) {
73
73
  const frontmatter = page.fragment || !page.title
74
74
  ? (page.template ? templateFm : undefined)
75
75
  : { title: page.title, ...carriedFrontmatter(page.frontmatter), ...(page.redirect ? { redirect: page.redirect } : {}), ...templateFm };
76
- const md = treeToDogsbayMd(page.tree, frontmatter ? { frontmatter } : undefined);
76
+ const md = treeToDogsbayMd(page.tree, { ...serialize, ...(frontmatter ? { frontmatter } : {}) });
77
77
  files.set(dogsbayMdPagePath(page), md.endsWith("\n") ? md : md + "\n");
78
78
  }
79
79
  // Nav as nav.json at the content root (the dogsbay-md importer + runtime
package/dist/serialize.js CHANGED
@@ -6,7 +6,7 @@ import { assertDirectiveField } from "./directive-guard.js";
6
6
  import { renderAttrs, splitProps } from "./attributes.js";
7
7
  import { pickCodeFence, pickDirectiveFence, normalizeTrailingWhitespace, } from "./escape.js";
8
8
  import { resolveOptions } from "./types.js";
9
- import { renderBlockDirective } from "./directives.js";
9
+ import { guardRule, renderBlockDirective } from "./directives.js";
10
10
  import { renderFrontmatter } from "./yaml.js";
11
11
  // Captions are inline Markdown; parse.ts imports this module too, but parseInline
12
12
  // is only called at render time, after both modules have loaded
@@ -618,7 +618,8 @@ function renderCallout(node, ctx) {
618
618
  const labelled = typeof node.meta?.closer === "string";
619
619
  if (ctx.githubCallouts && hasOnlyTypeProp && !labelled && GITHUB_CALLOUT_TYPES.has(type)) {
620
620
  const tag = `[!${type.toUpperCase()}]`;
621
- return [tag, body]
621
+ // a body opening with `---` under `[!NOTE]` made a setext heading of the tag: the callout was lost
622
+ return [tag, guardRule(body)]
622
623
  .join("\n")
623
624
  .split("\n")
624
625
  .map((l) => (l ? `> ${l}` : ">"))
@@ -1341,8 +1342,13 @@ function renderUnknown(node, ctx) {
1341
1342
  if (node.type === "container" && node.meta?.container === true && typeof name === "string" && /^[A-Za-z][\w-]*$/.test(name)) {
1342
1343
  const body = (node.children ?? []).map((c) => renderNode(c, ctx)).filter(Boolean).join("\n\n");
1343
1344
  const title = typeof node.meta?.title === "string" ? ` ${node.meta.title}` : "";
1345
+ // A nested prop (a `---` block the parser read, 2026-10-27) has no inline form: the YAML form,
1346
+ // which the parser reads on every directive, keeps it; the inline form dropped it silently.
1347
+ if (!title && Object.values(node.props ?? {}).some((v) => (v !== null && typeof v === "object") || (typeof v === "string" && v.includes("\n")))) {
1348
+ return renderBlockDirective({ name, props: node.props, body, yamlEligible: true }, ctx);
1349
+ }
1344
1350
  const attrs = renderAttrs(splitProps(node.props, [])).trim();
1345
- return `:::${name}${title}${attrs ? (title ? " " : "") + attrs : ""}\n${body}\n:::`;
1351
+ return `:::${name}${title}${attrs ? (title ? " " : "") + attrs : ""}\n${guardRule(body)}\n:::`;
1346
1352
  }
1347
1353
  switch (ctx.unknownNodes) {
1348
1354
  case "skip":
package/dist/yaml.d.ts CHANGED
@@ -14,5 +14,14 @@ export type YamlValue = string | number | boolean | null | undefined | Date | Ya
14
14
  * Render a flat or shallow-nested object as YAML.
15
15
  * Keys emitted in insertion order.
16
16
  */
17
- export declare function renderYaml(value: Record<string, YamlValue>, indent?: number): string;
17
+ /** `quoteStrings`: every string double-quoted (JSON), the form that always reads back. */
18
+ export declare function renderYaml(value: Record<string, YamlValue>, indent?: number, quoteStrings?: boolean): string;
19
+ /**
20
+ * `renderYaml`, and when js-yaml would not read the result back as the value (a shape the
21
+ * writer has no rule for), js-yaml's own dump instead: a directive's YAML body must always
22
+ * read back, or its props are a paragraph on the next parse.
23
+ */
24
+ export declare function renderYamlReadable(value: Record<string, YamlValue>): string;
25
+ /** What `v` expands to when every shared reference is written out, up to `cap` + 1. */
26
+ export declare function expandedSize(v: unknown, cap: number, memo?: Map<object, number>, depth?: number): number;
18
27
  export declare function renderFrontmatter(value: Record<string, YamlValue>): string;
package/dist/yaml.js CHANGED
@@ -12,12 +12,15 @@ import * as jsyaml from "js-yaml";
12
12
  * Render a flat or shallow-nested object as YAML.
13
13
  * Keys emitted in insertion order.
14
14
  */
15
- export function renderYaml(value, indent = 0) {
15
+ /** `quoteStrings`: every string double-quoted (JSON), the form that always reads back. */
16
+ export function renderYaml(value, indent = 0, quoteStrings = false) {
16
17
  const pad = " ".repeat(indent);
17
18
  const lines = [];
18
- for (const [key, val] of Object.entries(value)) {
19
+ for (const [rawKey, val] of Object.entries(value)) {
19
20
  if (val === undefined)
20
21
  continue;
22
+ // A key that is not prop-shaped is quoted, which the parser's props-block rule accepts
23
+ const key = /^[A-Za-z_][\w.-]*$/.test(rawKey) ? rawKey : JSON.stringify(rawKey);
21
24
  if (val === null) {
22
25
  lines.push(`${pad}${key}: null`);
23
26
  continue;
@@ -31,7 +34,7 @@ export function renderYaml(value, indent = 0) {
31
34
  continue;
32
35
  }
33
36
  if (typeof val === "string") {
34
- lines.push(`${pad}${key}: ${encodeScalar(val)}`);
37
+ lines.push(`${pad}${key}: ${quoteStrings ? JSON.stringify(val) : encodeScalar(val, indent)}`);
35
38
  continue;
36
39
  }
37
40
  if (val instanceof Date) {
@@ -46,7 +49,7 @@ export function renderYaml(value, indent = 0) {
46
49
  lines.push(`${pad}${key}:`);
47
50
  for (const item of val) {
48
51
  if (item === null || typeof item !== "object" || Array.isArray(item) || item instanceof Date) {
49
- lines.push(`${pad} - ${encodeItem(item)}`);
52
+ lines.push(`${pad} - ${quoteStrings && typeof item === "string" ? JSON.stringify(item) : encodeItem(item, indent + 2)}`);
50
53
  }
51
54
  else {
52
55
  // Object in array — inline form
@@ -57,7 +60,7 @@ export function renderYaml(value, indent = 0) {
57
60
  }
58
61
  if (typeof val === "object") {
59
62
  lines.push(`${pad}${key}:`);
60
- lines.push(renderYaml(val, indent + 2));
63
+ lines.push(renderYaml(val, indent + 2, quoteStrings));
61
64
  continue;
62
65
  }
63
66
  }
@@ -66,11 +69,18 @@ export function renderYaml(value, indent = 0) {
66
69
  /**
67
70
  * Encode a scalar value for YAML, picking the right quoting style.
68
71
  */
69
- function encodeScalar(value) {
70
- // Multi-line string: use literal block scalar
72
+ function encodeScalar(value, indent = 0) {
73
+ // Multi-line string: a literal block scalar, its content two past the KEY's indent (level with
74
+ // the key, a nested one did not read back); `|2` when the first line is blank or indented,
75
+ // since js-yaml takes the indentation from the first non-empty line otherwise; `|-` when the
76
+ // value has no trailing newline, so none is added on the way back.
71
77
  if (value.includes("\n")) {
72
- const lines = value.split("\n");
73
- return `|\n${lines.map((l) => " " + l).join("\n")}`;
78
+ const chomp = value.endsWith("\n") ? "" : "-";
79
+ const body = value.endsWith("\n") ? value.slice(0, -1) : value;
80
+ const lines = body.split("\n");
81
+ const pad = " ".repeat(indent + 2);
82
+ const indicator = lines[0] === "" || /^\s/.test(lines[0]) ? "2" : "";
83
+ return `|${indicator}${chomp}\n${lines.map((l) => (l === "" ? "" : pad + l)).join("\n")}`;
74
84
  }
75
85
  // Strings that look like numbers, booleans, or special YAML values need quoting
76
86
  if (needsQuoting(value)) {
@@ -90,7 +100,7 @@ function encodeDate(d) {
90
100
  const iso = d.toISOString();
91
101
  return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
92
102
  }
93
- function encodeItem(value) {
103
+ function encodeItem(value, indent = 0) {
94
104
  if (value === null || value === undefined)
95
105
  return "null";
96
106
  if (value instanceof Date)
@@ -98,15 +108,45 @@ function encodeItem(value) {
98
108
  if (typeof value === "boolean" || typeof value === "number")
99
109
  return String(value);
100
110
  if (typeof value === "string")
101
- return encodeScalar(value);
111
+ return encodeScalar(value, indent);
102
112
  return JSON.stringify(value);
103
113
  }
104
114
  function encodeInlineObject(obj) {
105
115
  const pairs = Object.entries(obj)
106
116
  .filter(([, v]) => v !== undefined)
107
- .map(([k, v]) => `${k}: ${encodeItem(v)}`);
117
+ .map(([k, v]) => `${/^[\w.-]+$/.test(k) ? k : JSON.stringify(k)}: ${encodeFlowItem(v)}`);
108
118
  return `{ ${pairs.join(", ")} }`;
109
119
  }
120
+ /** An item inside a flow mapping, where a comma, bracket or brace in a bare string would end the entry (Nuxt UI's FAQ items did). */
121
+ function encodeFlowItem(value) {
122
+ if (typeof value === "string") {
123
+ if (value.includes("\n") || /[,{}\[\]]/.test(value) || needsQuoting(value))
124
+ return JSON.stringify(value);
125
+ return value;
126
+ }
127
+ if (value !== null && typeof value === "object" && !(value instanceof Date))
128
+ return JSON.stringify(value);
129
+ return encodeItem(value);
130
+ }
131
+ /**
132
+ * `renderYaml`, and when js-yaml would not read the result back as the value (a shape the
133
+ * writer has no rule for), js-yaml's own dump instead: a directive's YAML body must always
134
+ * read back, or its props are a paragraph on the next parse.
135
+ */
136
+ export function renderYamlReadable(value) {
137
+ const text = renderYaml(value);
138
+ if (readsBackAs(text, value))
139
+ return text;
140
+ // js-yaml's dump, its one trailing newline removed (trimEnd took the newlines a `|+` keeps);
141
+ // and when even that does not read back, every string double-quoted (always reads back).
142
+ const dumped = stripOneNewline(jsyaml.dump(forDump(value), { lineWidth: -1, noRefs: true, skipInvalid: true }));
143
+ if (readsBackAs(dumped, value))
144
+ return dumped;
145
+ return renderYaml(value, 0, true);
146
+ }
147
+ function stripOneNewline(s) {
148
+ return s.endsWith("\n") ? s.slice(0, -1) : s;
149
+ }
110
150
  function needsQuoting(value) {
111
151
  if (value === "")
112
152
  return true;
@@ -134,7 +174,7 @@ function needsQuoting(value) {
134
174
  /** Values a front matter may expand to when written out in full (see renderFrontmatter). */
135
175
  const MAX_FRONTMATTER_VALUES = 100_000;
136
176
  /** What `v` expands to when every shared reference is written out, up to `cap` + 1. */
137
- function expandedSize(v, cap, memo = new Map(), depth = 0) {
177
+ export function expandedSize(v, cap, memo = new Map(), depth = 0) {
138
178
  if (v === null || typeof v !== "object" || v instanceof Date)
139
179
  return 1;
140
180
  const known = memo.get(v);
@@ -157,7 +197,9 @@ export function renderFrontmatter(value) {
157
197
  // GitBook met it 2026-10-20). Such a value is written with its aliases kept; every other page
158
198
  // is written exactly as before.
159
199
  if (expandedSize(value, MAX_FRONTMATTER_VALUES) > MAX_FRONTMATTER_VALUES) {
160
- return `---\n${jsyaml.dump(value, { lineWidth: -1, noRefs: false, skipInvalid: true }).trimEnd()}\n---`;
200
+ return `---
201
+ ${stripOneNewline(jsyaml.dump(value, { lineWidth: -1, noRefs: false, skipInvalid: true }))}
202
+ ---`;
161
203
  }
162
204
  const yaml = renderYaml(value);
163
205
  if (readsBackAs(yaml, value))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/format-dogsbay-md",
3
- "version": "0.2.0-beta.136",
3
+ "version": "0.2.0-beta.138",
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",
@@ -40,10 +40,10 @@
40
40
  "markdown-it-footnote": "^4.0.0",
41
41
  "markdown-it-front-matter": "^0.2.4",
42
42
  "pele": "1.6.0",
43
- "@dogsbay/minja": "0.2.0-beta.136",
44
- "@dogsbay/serialize-core": "0.2.0-beta.136",
45
- "@dogsbay/types": "0.2.0-beta.136",
46
- "@dogsbay/corpus": "0.2.0-beta.136"
43
+ "@dogsbay/corpus": "0.2.0-beta.138",
44
+ "@dogsbay/minja": "0.2.0-beta.138",
45
+ "@dogsbay/serialize-core": "0.2.0-beta.138",
46
+ "@dogsbay/types": "0.2.0-beta.138"
47
47
  },
48
48
  "license": "MIT",
49
49
  "repository": {