@dogsbay/format-dogsbay-md 0.2.0-beta.142 → 0.2.0-beta.144

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
@@ -16,6 +16,7 @@ export { KNOWN_INLINE_DIRECTIVES, type ParseDiagnostic } from "./diagnostics.js"
16
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 { DIRECTIVE_PROPS, PASSTHROUGH_PROP, unknownProps } from "./directive-props.js";
19
+ export { DIRECTIVE_DESCRIPTIONS, INLINE_DIRECTIVE_DESCRIPTIONS } from "./directive-descriptions.js";
19
20
  export type { SerializeOptions } from "./types.js";
20
21
  export { inlineToDogsbayMd } from "./inline.js";
21
22
  export { transformCodeRegions, transformProseLines, transformProseSegments, fenceOpenBefore, createFenceTracker, } from "./code-regions.js";
package/dist/browser.js CHANGED
@@ -18,6 +18,7 @@ export { KNOWN_BLOCK_DIRECTIVES, LEAF_ONLY_DIRECTIVES, BLOCK_LEAF_DIRECTIVES } f
18
18
  export { treeToDogsbayMd, nodeToDogsbayMd } from "./serialize.js";
19
19
  // The props each known directive draws (an editor completes prop names from it)
20
20
  export { DIRECTIVE_PROPS, PASSTHROUGH_PROP, unknownProps } from "./directive-props.js";
21
+ export { DIRECTIVE_DESCRIPTIONS, INLINE_DIRECTIVE_DESCRIPTIONS } from "./directive-descriptions.js";
21
22
  export { inlineToDogsbayMd } from "./inline.js";
22
23
  export { transformCodeRegions, transformProseLines, transformProseSegments, fenceOpenBefore, createFenceTracker, } from "./code-regions.js";
23
24
  export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcessedFence, stripInlineMinjaMarkers, stripFenceMinjaMarkers } from "./minja-mask.js";
@@ -10,6 +10,14 @@ export interface ParseDiagnostic {
10
10
  * reading it out of the message.
11
11
  */
12
12
  name?: string;
13
+ /**
14
+ * 1-based column where the problem starts on `line`, and `endColumn` just past its end (also
15
+ * 1-based, exclusive), when the parser knows which part of the line it is about: the `:::name`
16
+ * opener, the prop key, the closer's comment, the `{…}` block. Found in the SOURCE line, so a
17
+ * pre-pass that rewrites the text cannot shift it. Left out, the whole line is meant.
18
+ */
19
+ column?: number;
20
+ endColumn?: number;
13
21
  }
14
22
  /**
15
23
  * Whether a source line closes a container opened with `colons` colons. The
@@ -47,5 +55,5 @@ type Token = {
47
55
  * missing brace or an unterminated quote loses attributes silently.
48
56
  */
49
57
  export declare function attributeProblem(block: string): string | undefined;
50
- export declare function collectDiagnostics(tokens: Token[], lines: string[], lineOf: (line0: number) => number | undefined): ParseDiagnostic[];
58
+ export declare function collectDiagnostics(tokens: Token[], lines: string[], lineOf: (line0: number) => number | undefined, sourceLine?: (line: number) => string | undefined): ParseDiagnostic[];
51
59
  export {};
@@ -12,6 +12,36 @@ import { BLOCK_LEAF_DIRECTIVES, GENERIC_CONTAINER, LEAF_ONLY_DIRECTIVES } from "
12
12
  import { LABEL_PROP } from "./plugin-inline-directives.js";
13
13
  import { DIRECTIVE_PROPS, unknownProps } from "./directive-props.js";
14
14
  import * as yaml from "js-yaml";
15
+ /** The first match of `re` at or after `from`; a group named `at` narrows it (a key before its `=`). */
16
+ function find(re, mask) {
17
+ const span = (text, from) => {
18
+ const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`);
19
+ g.lastIndex = from;
20
+ const m = g.exec(mask ? mask(text) : text);
21
+ if (!m || m[0].length === 0)
22
+ return undefined;
23
+ const at = m.groups?.at;
24
+ const start = at !== undefined ? m.index + m[0].indexOf(at) : m.index;
25
+ return [start, start + (at ?? m[0]).length];
26
+ };
27
+ span.pattern = re.source;
28
+ return span;
29
+ }
30
+ /** Inline code spans blanked, offsets kept: a directive shown in code is not the one meant. */
31
+ const maskCode = (text) => text.replace(/(`+)[\s\S]*?\1/g, (c) => " ".repeat(c.length));
32
+ /** Quoted attribute values blanked, offsets kept: a key's name inside another's value is not the key. */
33
+ const maskQuotes = (text) => text.replace(/"[^"]*"|'[^']*'/g, (c) => " ".repeat(c.length));
34
+ /**
35
+ * The attribute block a line ends with (`{…}`, maybe unclosed, then spaces), found from the last
36
+ * `{` so the test is linear: anchored at `$`, the regex backtracked quadratically on a long line.
37
+ */
38
+ function trailingBraces(text, inner = /^\{[^{}]*\}?$/) {
39
+ const t = text.trimEnd();
40
+ const i = t.lastIndexOf("{");
41
+ return i >= 0 && inner.test(t.slice(i)) ? [i, t.length] : undefined;
42
+ }
43
+ const HEADING_ATTRS = /^\{\s*(?:[#.][^{}]*|[\w-]+\s*=[^{}]*)\}?$/;
44
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15
45
  /**
16
46
  * Whether a source line closes a container opened with `colons` colons. The
17
47
  * line may sit in a blockquote or a list item: its `>` markers and
@@ -155,12 +185,32 @@ function propKeysOfAttrs(props) {
155
185
  }
156
186
  return keys;
157
187
  }
158
- export function collectDiagnostics(tokens, lines, lineOf) {
188
+ export function collectDiagnostics(tokens, lines, lineOf, sourceLine) {
159
189
  const out = [];
160
- const add = (line0, code, message, name) => {
190
+ // Where the last span of each (line, code) ended: two of one kind on a line take successive matches
191
+ const cursor = new Map();
192
+ const add = (line0, code, message, name, span) => {
161
193
  const line = line0 === undefined ? undefined : lineOf(line0);
162
- if (line !== undefined)
163
- out.push({ line, code, message, ...(name === undefined ? {} : { name }) });
194
+ if (line === undefined)
195
+ return;
196
+ const text = span && sourceLine?.(line);
197
+ let cols;
198
+ if (text !== undefined && span) {
199
+ const locate = span instanceof RegExp ? find(span) : span;
200
+ // Per pattern too: bold `{…}` and `:kbd[…]{…}` on one line are found independently
201
+ const key = `${line}\0${code}\0${locate.pattern ?? ""}`;
202
+ cols = locate(text, cursor.get(key) ?? 0);
203
+ if (cols)
204
+ cursor.set(key, cols[1]);
205
+ }
206
+ out.push({ line, code, message, ...(name === undefined ? {} : { name }), ...(cols ? { column: cols[0] + 1, endColumn: cols[1] + 1 } : {}) });
207
+ };
208
+ const opener = (name) => new RegExp(`:{3,}[ \\t]*${escapeRe(name)}(?![\\w-])`);
209
+ const BRACES = (text) => trailingBraces(text);
210
+ /** Lines into an inline token where each child starts: soft and hard breaks move to the next. */
211
+ const childLines = (children) => {
212
+ let n = 0;
213
+ return children.map((c) => (c.type === "softbreak" || c.type === "hardbreak" ? n++ : n));
164
214
  };
165
215
  const code = codeLineSet(tokens);
166
216
  let row;
@@ -186,22 +236,22 @@ export function collectDiagnostics(tokens, lines, lineOf) {
186
236
  // (plans/youtube-leaf.md). A blank line in a quote is `>`, so quote markers are not content.
187
237
  const leafAsBlock = name === "youtube" ? youtubeAsBlock(tok.map, closed, lines, around, lineOf) : undefined;
188
238
  if (leafAsBlock) {
189
- add(tok.map[0], "leaf-as-block", `a video has no body: write ::youtube{…} on one line, with no closer — as :::youtube it ${leafAsBlock}`);
239
+ add(tok.map[0], "leaf-as-block", `a video has no body: write ::youtube{…} on one line, with no closer — as :::youtube it ${leafAsBlock}`, undefined, opener(name));
190
240
  }
191
241
  else if (!closed && legacy !== undefined && !generic) {
192
242
  // Before the nesting stack, the closer on that line ended this block and the ones inside it
193
- add(tok.map[0], "unclosed-container", `:::${name} is never closed — the ${clip(lines[legacy])} on line ${lineOf(legacy) ?? "?"} closes only the innermost open block; each block needs its own closer (one \`:::\` used to close several)`);
243
+ add(tok.map[0], "unclosed-container", `:::${name} is never closed — the ${clip(lines[legacy])} on line ${lineOf(legacy) ?? "?"} closes only the innermost open block; each block needs its own closer (one \`:::\` used to close several)`, undefined, opener(name));
194
244
  }
195
245
  else if (!closed && skipped !== undefined) {
196
- add(tok.map[0], "unclosed-container", `:::${name} is never closed — the ${clip(lines[skipped])} on line ${lineOf(skipped) ?? "?"} is inside a code block, so it does not close it (add a closer after the code)`);
246
+ add(tok.map[0], "unclosed-container", `:::${name} is never closed — the ${clip(lines[skipped])} on line ${lineOf(skipped) ?? "?"} is inside a code block, so it does not close it (add a closer after the code)`, undefined, opener(name));
197
247
  }
198
248
  else if (!closed) {
199
249
  add(tok.map[0], "unclosed-container", generic
200
250
  ? `:::${name} is never closed — it is shown as text (close it with a line of ${":".repeat(colons)})`
201
- : `:::${name} is never closed — it takes in everything after it, to the end of the page or of the block it sits in (close it with a line of ${":".repeat(colons)})`);
251
+ : `:::${name} is never closed — it takes in everything after it, to the end of the page or of the block it sits in (close it with a line of ${":".repeat(colons)})`, undefined, opener(name));
202
252
  }
203
253
  else if (generic) {
204
- add(tok.map[0], "unknown-container", `:::${name} is not a known directive — it is shown as a plain box`, name);
254
+ add(tok.map[0], "unknown-container", `:::${name} is not a known directive — it is shown as a plain box`, name, opener(name));
205
255
  }
206
256
  else {
207
257
  // A prop no renderer of this directive reads is carried, never drawn: say so (a typo
@@ -209,26 +259,28 @@ export function collectDiagnostics(tokens, lines, lineOf) {
209
259
  // lifted into `__yaml__` by preprocessYamlBodies.
210
260
  // markdown-it-attrs put the opener's `{…}` on the token's attrs; the lifted YAML block is `__yaml__`
211
261
  const pairs = (tok.attrs ?? []);
262
+ const inline = new Set(pairs.map(([k]) => k));
212
263
  for (const key of unknownProps(name, propKeysOfAttrs(Object.fromEntries(pairs)))) {
213
- add(tok.map[0], "unknown-prop", `${key} is not a prop of :::${name} — it is kept but not drawn`, name);
264
+ // A key from a `---` props block is on another line: no column
265
+ add(tok.map[0], "unknown-prop", `${key} is not a prop of :::${name} — it is kept but not drawn`, name, inline.has(key) ? find(new RegExp(`(?<![\\w-])(?<at>${escapeRe(key)})\\s*=`), maskQuotes) : undefined);
214
266
  }
215
267
  }
216
268
  if (tok.meta?.unverified) {
217
- 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`);
269
+ 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`, undefined, opener(name));
218
270
  }
219
271
  if (closed) {
220
272
  // The closer's end-tag label: it closes this container whatever it says, so a wrong
221
273
  // name means the author thinks it closes another one
222
274
  const label = closerLabel((lines[tok.map[1]] ?? "").replace(/^[\s>]*:+/, ""));
223
275
  if (label && label !== name) {
224
- add(tok.map[1], "closer-mismatch", `this closer is labelled ${label}, but it closes :::${name} (opened on line ${lineOf(tok.map[0]) ?? "?"}) — check which block you meant to end here`);
276
+ add(tok.map[1], "closer-mismatch", `this closer is labelled ${label}, but it closes :::${name} (opened on line ${lineOf(tok.map[0]) ?? "?"}) — check which block you meant to end here`, undefined, /<!--[\s\S]*?-->/);
225
277
  }
226
278
  }
227
279
  const rest = info.slice(name.length).trim();
228
280
  if (rest.startsWith("{")) {
229
281
  const problem = attributeProblem(rest);
230
282
  if (problem)
231
- add(tok.map[0], "bad-attributes", `the attributes of :::${name} ${problem} — they are ignored`);
283
+ add(tok.map[0], "bad-attributes", `the attributes of :::${name} ${problem} — they are ignored`, undefined, /\{.*$/);
232
284
  }
233
285
  // An unknown block never closed is read back as text, so it is no parent (but still popped)
234
286
  around.push({ map: tok.map, closed: closed || (generic && tok.meta?.unclosed === true), name });
@@ -237,7 +289,7 @@ export function collectDiagnostics(tokens, lines, lineOf) {
237
289
  if (tok.type === "inline" && tok.map) {
238
290
  (tok.content ?? "").split("\n").forEach((l, k) => {
239
291
  if (/^\s*:{3,}[ \t]*(?:<!--(?:(?!-->)[\s\S])*-->[ \t]*)?$/.test(l)) {
240
- add(tok.map[0] + k, "stray-closer", `stray \`${clip(l)}\` — it closes nothing and is shown as text: every block before it is already closed (each block takes one closer)`);
292
+ add(tok.map[0] + k, "stray-closer", `stray \`${clip(l)}\` — it closes nothing and is shown as text: every block before it is already closed (each block takes one closer)`, undefined, /:{3,}/);
241
293
  }
242
294
  });
243
295
  }
@@ -264,7 +316,7 @@ export function collectDiagnostics(tokens, lines, lineOf) {
264
316
  const at = text.indexOf(form, from);
265
317
  from = at >= 0 ? at + form.length : from;
266
318
  const offset = at >= 0 ? (text.slice(0, at).match(/\n/g) ?? []).length : 0;
267
- add(base + offset, "missing-label", `:${child.tag}{…} has no label — it is shown as text; write :${child.tag}[…]{…} or :${child.tag}{${key}="…"}`);
319
+ add(base + offset, "missing-label", `:${child.tag}{…} has no label — it is shown as text; write :${child.tag}[…]{…} or :${child.tag}{${key}="…"}`, undefined, find(new RegExp(`(?<!:):${escapeRe(child.tag)}\\{[^{}]*\\}?`), maskCode));
268
320
  }
269
321
  continue;
270
322
  }
@@ -294,21 +346,21 @@ export function collectDiagnostics(tokens, lines, lineOf) {
294
346
  const shown = child.type === "dogsbay_leaf_directive" ? `::${child.tag}` : `:${child.tag}[…]`;
295
347
  if (child.type === "dogsbay_leaf_directive" && BLOCK_DIRECTIVES.has(child.tag)) {
296
348
  if (labelled) {
297
- add(base + offset, "block-directive-inline", `${shown}[…] is shown as text: a block directive takes no [label]. ${blockForm(child.tag)}`);
349
+ add(base + offset, "block-directive-inline", `${shown}[…] is shown as text: a block directive takes no [label]. ${blockForm(child.tag)}`, undefined, find(new RegExp(`::${escapeRe(child.tag)}\\[[^\\]]*\\]?`), maskCode));
298
350
  }
299
351
  else {
300
- 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`);
352
+ 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`, undefined, find(new RegExp(`::${escapeRe(child.tag)}(?![\\w-])`), maskCode));
301
353
  }
302
354
  }
303
355
  else {
304
- add(base + offset, "unknown-inline", `${shown} is not a known inline directive — it is shown as text`, child.tag);
356
+ add(base + offset, "unknown-inline", `${shown} is not a known inline directive — it is shown as text`, child.tag, find(child.type === "dogsbay_leaf_directive" ? new RegExp(`::${escapeRe(child.tag)}(?![\\w-])`) : new RegExp(`(?<!:):${escapeRe(child.tag)}\\[[^\\]]*\\]?`), maskCode));
305
357
  }
306
358
  }
307
359
  }
308
360
  // Attribute blocks markdown-it-attrs read and the tree has nowhere to keep
309
361
  if (tok.type === "list_item_open" && tok.map && tok.attrs?.length) {
310
362
  // parse.ts keepListItemBraces puts trailing braces back as text, or on a heading; what is left had nowhere to go
311
- 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`);
363
+ 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`, undefined, BRACES);
312
364
  }
313
365
  // A table row that is only an attribute block, where the parser could not move it onto the
314
366
  // table (not the last row, or text follows the table with no blank line): an empty row
@@ -318,14 +370,14 @@ export function collectDiagnostics(tokens, lines, lineOf) {
318
370
  cells.push(list[k]);
319
371
  const inlines = cells.filter((c) => c.type === "inline");
320
372
  if (cells[0]?.attrs?.length && /^\{[^{}]*\}$/.test((inlines[0]?.content ?? "").trim()) && inlines.slice(1).every((c) => c.content.trim() === "")) {
321
- add(tok.map[0], "dropped-attributes", `this attribute block is read as an empty table row — put it on the line directly under the table's last row, or after a blank line`);
373
+ add(tok.map[0], "dropped-attributes", `this attribute block is read as an empty table row — put it on the line directly under the table's last row, or after a blank line`, undefined, BRACES);
322
374
  }
323
375
  }
324
376
  // markdown-it-attrs gives a list a block on the line after it (or after its last item's text); the tree keeps no list attributes
325
377
  // Only real attribute names: markdown-it-attrs also reads a leftover include (`{* src.py hl[4] *}`, FastAPI) as keys
326
378
  const own = tok.attrs?.filter(([k]) => k !== "start") ?? [];
327
379
  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))) {
328
- 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`);
380
+ 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`, undefined, BRACES);
329
381
  }
330
382
  // An attribute block alone on its line after a blank line: an empty paragraph that carries it
331
383
  if (tok.type === "paragraph_open" && tok.map && tok.attrs?.length) {
@@ -334,17 +386,18 @@ export function collectDiagnostics(tokens, lines, lineOf) {
334
386
  // leftover MDX expression (`{/* prettier-ignore */}`), which is an importer's problem, not the author's
335
387
  const looksLikeAttrs = /^\{\s*(?:[#.][^\s{}]|[\w-]+\s*=)/.test(inline?.content.trim() ?? "");
336
388
  if (inline?.type === "inline" && looksLikeAttrs && (inline.children ?? []).every((c) => c.type === "text" && c.content.trim() === "")) {
337
- add(tok.map[0], "dropped-attributes", `this attribute block is on a line of its own and applies to nothing — put it at the end of the block's last line`);
389
+ add(tok.map[0], "dropped-attributes", `this attribute block is on a line of its own and applies to nothing — put it at the end of the block's last line`, undefined, BRACES);
338
390
  }
339
391
  }
340
392
  if (tok.type === "inline" && tok.map && tok.children) {
341
- for (const child of tok.children) {
393
+ const at = childLines(tok.children);
394
+ for (const [k, child] of tok.children.entries()) {
342
395
  const what = ATTRLESS_INLINE[child.type];
343
396
  if (what && child.attrs?.length)
344
- add(tok.map[0], "dropped-attributes", `the attribute block on ${what} is dropped — only links, images and inline directives take attributes`);
397
+ add(tok.map[0] + at[k], "dropped-attributes", `the attribute block on ${what} is dropped — only links, images and inline directives take attributes`, undefined, /(?<=[*_`~])\{[^{}]*\}/);
345
398
  }
346
399
  // Inline directives that keep no attributes (only badge and button do)
347
- for (const child of tok.children) {
400
+ for (const [k, child] of tok.children.entries()) {
348
401
  if (child.type !== "dogsbay_inline_directive_open" || child.tag === "badge" || child.tag === "button" || !KNOWN_INLINE_DIRECTIVES.has(child.tag))
349
402
  continue;
350
403
  let parsed = {};
@@ -363,7 +416,7 @@ export function collectDiagnostics(tokens, lines, lineOf) {
363
416
  delete props[key];
364
417
  }
365
418
  if (parsed.id || parsed.classes?.length || Object.keys(props).length) {
366
- add(tok.map[0], "dropped-attributes", `the attribute block on :${child.tag}[…] is dropped — of the inline directives only :badge and :button take attributes`);
419
+ add(tok.map[0] + at[k], "dropped-attributes", `the attribute block on :${child.tag}[…] is dropped — of the inline directives only :badge and :button take attributes`, undefined, find(new RegExp(`:${escapeRe(child.tag)}\\[[^\\]]*\\](?<at>\\{[^{}]*\\})`), maskCode));
367
420
  }
368
421
  }
369
422
  }
@@ -375,10 +428,11 @@ export function collectDiagnostics(tokens, lines, lineOf) {
375
428
  const inline = list[i + 1];
376
429
  // markdown-it-attrs takes a block it reads out of the text tokens (not out of `content`)
377
430
  const last = [...(inline?.children ?? [])].reverse().find((c) => c.type === "text");
378
- const tail = /\{\s*(?:[#.][^{}]*|[\w-]+\s*=[^{}]*)\}?\s*$/.exec(last?.content ?? "");
431
+ const content = last?.content ?? "";
432
+ const tail = trailingBraces(content, HEADING_ATTRS);
379
433
  if (tail) {
380
- const problem = attributeProblem(tail[0]) ?? "could not be read";
381
- add(tok.map[0], "bad-attributes", `the heading's attribute block ${problem} — it is shown as text`);
434
+ const problem = attributeProblem(content.slice(tail[0])) ?? "could not be read";
435
+ add(tok.map[0], "bad-attributes", `the heading's attribute block ${problem} — it is shown as text`, undefined, (text) => trailingBraces(text, HEADING_ATTRS));
382
436
  }
383
437
  }
384
438
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * One line per directive name, for an editor's completion detail (the VS Code extension asked,
3
+ * 2026-10-29): the name lists (`KNOWN_BLOCK_DIRECTIVES`, `LEAF_ONLY_DIRECTIVES`,
4
+ * `KNOWN_INLINE_DIRECTIVES`) are bare strings. Block and leaf names share one map; an inline
5
+ * directive (`:name[…]`) means something else under the same name (`:math[x]` vs `:::math`),
6
+ * so it has its own. A test fails on a listed name with no description.
7
+ */
8
+ /** `:::name` / `::name` — block and leaf directives. */
9
+ export declare const DIRECTIVE_DESCRIPTIONS: Readonly<Record<string, string>>;
10
+ /** `:name[…]{…}` — inline directives. */
11
+ export declare const INLINE_DIRECTIVE_DESCRIPTIONS: Readonly<Record<string, string>>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * One line per directive name, for an editor's completion detail (the VS Code extension asked,
3
+ * 2026-10-29): the name lists (`KNOWN_BLOCK_DIRECTIVES`, `LEAF_ONLY_DIRECTIVES`,
4
+ * `KNOWN_INLINE_DIRECTIVES`) are bare strings. Block and leaf names share one map; an inline
5
+ * directive (`:name[…]`) means something else under the same name (`:math[x]` vs `:::math`),
6
+ * so it has its own. A test fails on a listed name with no description.
7
+ */
8
+ const CALLOUT = (kind) => `Callout: ${kind}. \`title\`, \`collapsed\` for a fold`;
9
+ /** `:::name` / `::name` — block and leaf directives. */
10
+ export const DIRECTIVE_DESCRIPTIONS = {
11
+ tabs: "Tab group; each `:::tab[Label]` is one tab. `syncKey` syncs groups across the site",
12
+ tab: "One tab of a `:::tabs` group, named by its label",
13
+ steps: "Numbered procedure; each `:::step[Title]` is one step",
14
+ step: "One step of a `:::steps` procedure",
15
+ cards: "Card grid; `:::card` children, or a list of links. `cols` sets the columns",
16
+ card: "Card with a title, optional `href`, `icon` and `image`",
17
+ "link-card": "Card that is one link: `href`, `title`, `description`",
18
+ grid: "Layout grid of `::grid-item` cells. `cols`, `gap`",
19
+ "grid-item": "One cell of a `:::grid`; `span`, `rowSpan`",
20
+ details: "Collapsible section; the label is the summary. `open` starts expanded",
21
+ callout: "Callout of a given `type` (note, tip, warning, …)",
22
+ note: CALLOUT("note"),
23
+ tip: CALLOUT("tip"),
24
+ info: CALLOUT("info"),
25
+ important: CALLOUT("important"),
26
+ warning: CALLOUT("warning"),
27
+ caution: CALLOUT("caution"),
28
+ danger: CALLOUT("danger"),
29
+ success: CALLOUT("success"),
30
+ hint: CALLOUT("hint"),
31
+ attention: CALLOUT("attention"),
32
+ error: CALLOUT("error"),
33
+ failure: CALLOUT("failure"),
34
+ bug: CALLOUT("bug"),
35
+ question: CALLOUT("question"),
36
+ abstract: CALLOUT("abstract"),
37
+ quote: CALLOUT("quote"),
38
+ button: "Link styled as a button: `href`, `variant`, `size`",
39
+ figure: "Image or content with a `caption`",
40
+ math: "Display math: the LaTeX as its body, or `::math{latex=\"…\"}`; same as `$$…$$`",
41
+ "api-endpoint": "API operation card (method, path, description)",
42
+ "api-params": "API parameter table",
43
+ "api-response": "API response with status and schema",
44
+ config: "Configuration reference block",
45
+ files: "File tree, from a nested list",
46
+ example: "Example: rendered output beside its source",
47
+ accordion: "Accordion; `:::accordion-item` children. `type` single or multiple",
48
+ "accordion-item": "One fold of an `:::accordion`, titled by its label",
49
+ autodoc: "Python API reference generated from source at build time",
50
+ "api-class": "Generated API reference: a class",
51
+ "api-member": "Generated API reference: a member",
52
+ "api-doc": "Generated API reference: a docstring",
53
+ "api-source": "Generated API reference: source code",
54
+ youtube: "YouTube video, loaded on click: `::youtube{#id title=\"…\"}`",
55
+ openapi: "API reference from an OpenAPI spec: `spec=\"/path/openapi.yaml\"`",
56
+ theme: "Content in another theme's tokens: `name=\"dark\"`",
57
+ avatar: "Avatar image with a fallback: `src`, `alt`, `fallback`, `size`",
58
+ "child-links": "Links to this page's children in the menu; `depth`",
59
+ "data-table": "Table from a data file: `src`, `path` to a list in it, `columns`, `headers`",
60
+ "data-cards": "Cards from a data file: `src`, `path`; `title`, `description`, `href`, `icon` name its keys",
61
+ };
62
+ /** `:name[…]{…}` — inline directives. */
63
+ export const INLINE_DIRECTIVE_DESCRIPTIONS = {
64
+ kbd: "Keyboard key: `:kbd[Ctrl+C]`",
65
+ icon: "Icon by name: `:icon[check]`",
66
+ math: "Inline math: `:math[x^2]`",
67
+ mark: "Highlighted text",
68
+ highlight: "Highlighted text (same as `mark`)",
69
+ button: "Inline link button: `:button[Label]{href=\"…\"}`",
70
+ badge: "Badge: `:badge[New]{variant=\"…\"}`",
71
+ };
@@ -31,9 +31,10 @@ export const DIRECTIVE_PROPS = {
31
31
  avatar: ["src", "alt", "fallback", "source", "size"],
32
32
  accordion: ["type", "defaultValue", "variant"],
33
33
  "accordion-item": ["title", "label", "value", "open"],
34
- math: [],
35
- "data-table": ["src", "columns", "caption", "sortable", "filter", "paginate"],
36
- "data-cards": ["src", "cols", "tone"],
34
+ math: ["latex"],
35
+ // What data-directives.ts reads (docs/data-files.md)
36
+ "data-table": ["src", "path", "columns", "headers"],
37
+ "data-cards": ["src", "path", "title", "description", "href", "icon"],
37
38
  "child-links": ["depth", "title"],
38
39
  };
39
40
  /** Keys of `props` that `name` does not draw; empty when the directive is not checked. */
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ 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
12
  export { DIRECTIVE_PROPS, PASSTHROUGH_PROP, unknownProps } from "./directive-props.js";
13
+ export { DIRECTIVE_DESCRIPTIONS, INLINE_DIRECTIVE_DESCRIPTIONS } from "./directive-descriptions.js";
13
14
  export { buildNavFromDirectory } from "./nav.js";
14
15
  export type { BuildNavOptions } from "./nav.js";
15
16
  export { loadNavFile } from "./nav-file.js";
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ 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
15
  export { DIRECTIVE_PROPS, PASSTHROUGH_PROP, unknownProps } from "./directive-props.js";
16
+ export { DIRECTIVE_DESCRIPTIONS, INLINE_DIRECTIVE_DESCRIPTIONS } from "./directive-descriptions.js";
16
17
  export { buildNavFromDirectory } from "./nav.js";
17
18
  export { loadNavFile } from "./nav-file.js";
18
19
  export { renderDiagrams } from "./renderers/diagrams.js";
package/dist/parse.js CHANGED
@@ -25,6 +25,11 @@ import { splitFrontmatter } from "./frontmatter.js";
25
25
  import { protectDirectives, restoreDirectives } from "./directive-preserve.js";
26
26
  import { closesContainer, collectDiagnostics } from "./diagnostics.js";
27
27
  import { treeToDogsbayMd } from "./serialize.js";
28
+ /** A 1-based line of the source as written, for a diagnostic's columns (split once, on first use). */
29
+ function sourceLineOf(source) {
30
+ let lines;
31
+ return (line) => (lines ??= source.split(/\r\n|\n/))[line - 1];
32
+ }
28
33
  /**
29
34
  * Parse Dogsbay Markdown into TreeNode[] plus extracted frontmatter.
30
35
  */
@@ -100,7 +105,7 @@ export function dogsbayMdToTree(source, options) {
100
105
  attachTableAttrsRow(tokens, content);
101
106
  // Before tokensToTree: it changes nothing these read, but read the parse as markdown-it left it
102
107
  // A lone-CR source never gets here with diagnostics (see the top), so `lineOf` is set
103
- let diagnostics = options?.diagnostics ? collectDiagnostics(tokens, content.split(/\r\n|\n/), lineOf ?? (() => undefined)) : undefined;
108
+ let diagnostics = options?.diagnostics ? collectDiagnostics(tokens, content.split(/\r\n|\n/), lineOf ?? (() => undefined), sourceLineOf(source)) : undefined;
104
109
  if (diagnostics && lineOf && yamlLifted.badProps.length) {
105
110
  for (const bad of yamlLifted.badProps) {
106
111
  // The line YAML stops at, mapped itself: protected directives can add or stash lines in
@@ -112,7 +117,7 @@ export function dogsbayMdToTree(source, options) {
112
117
  }
113
118
  diagnostics = diagnostics.sort((a, b) => a.line - b.line);
114
119
  }
115
- let tree = foldRelated(tokensToTree(tokens, options?.sourceLines ? lineOf : undefined));
120
+ let tree = foldRelated(tokensToTree(tokens, options?.sourceLines ? lineOf : undefined, content.split(/\r\n|\n/)));
116
121
  if (directiveTable) {
117
122
  // `preserve` restores every construct as a first-class node. `resolve-first`
118
123
  // restores INCLUDES as nodes too — they are transclusions of real content,
@@ -518,9 +523,9 @@ function markUnclosedGenericContainers(tokens, src) {
518
523
  tok.meta = { ...(tok.meta ?? {}), unclosed: true };
519
524
  }
520
525
  }
521
- function tokensToTree(tokens, lineOf) {
526
+ function tokensToTree(tokens, lineOf, lines) {
522
527
  const nodes = [];
523
- const ctx = { tokens, i: 0, ...(lineOf ? { lineOf } : {}) };
528
+ const ctx = { tokens, i: 0, ...(lineOf ? { lineOf } : {}), ...(lines ? { lines } : {}) };
524
529
  while (ctx.i < ctx.tokens.length) {
525
530
  const node = consumeBlock(ctx);
526
531
  if (node) {
@@ -1173,6 +1178,13 @@ function consumeContainer(ctx) {
1173
1178
  ctx.i++;
1174
1179
  }
1175
1180
  }
1181
+ // A `:::math` body is LaTeX, not Markdown: read its lines as written (parsed, `\\` lost a
1182
+ // backslash and `{aligned}` became attributes), and give it the shape `$$…$$` has, which every
1183
+ // renderer draws. Its children were rendered by nothing: render-html drew an empty block.
1184
+ if (name === "math" && registered !== GENERIC_CONTAINER) {
1185
+ const latex = openTok.map && ctx.lines ? containerBody(ctx.lines, openTok.map) : children.map(nodeText).join("\n");
1186
+ return { type: "math-block", props: { latex } };
1187
+ }
1176
1188
  if (registered === GENERIC_CONTAINER) {
1177
1189
  // `:::name` with no closing `:::`: the line was prose (as before generic containers), not a block
1178
1190
  // that swallows the rest of the page.
@@ -1196,6 +1208,34 @@ function consumeContainer(ctx) {
1196
1208
  const node = mapContainerToTreeNode(name, attrs, children);
1197
1209
  return closer && !Array.isArray(node) ? { ...node, meta: { ...(node.meta ?? {}), closer } } : node;
1198
1210
  }
1211
+ /**
1212
+ * A container's body lines as written: between its opener and its closer (`map[1]`, or the end
1213
+ * when unclosed), less the opener's own indent and `>` markers, blank lines at either end dropped.
1214
+ */
1215
+ function containerBody(lines, map) {
1216
+ const prefix = /^[ \t>]*/.exec(lines[map[0]] ?? "")[0];
1217
+ const body = lines.slice(map[0] + 1, Math.min(map[1], lines.length)).map((l) => {
1218
+ if (l.startsWith(prefix))
1219
+ return l.slice(prefix.length);
1220
+ // A lazy or blank quote line: drop what of the prefix it has
1221
+ let k = 0;
1222
+ while (k < prefix.length && k < l.length && l[k] === prefix[k])
1223
+ k++;
1224
+ return l.slice(k);
1225
+ });
1226
+ while (body.length && body[0].trim() === "")
1227
+ body.shift();
1228
+ while (body.length && body[body.length - 1].trim() === "")
1229
+ body.pop();
1230
+ return body.join("\n");
1231
+ }
1232
+ /** A block's text, its paragraphs' inline text joined (the fallback with no source lines). */
1233
+ function nodeText(n) {
1234
+ const inline = n.inline;
1235
+ if (inline)
1236
+ return inline.map((x) => x.text ?? x.latex ?? "").join("");
1237
+ return (n.children ?? []).map(nodeText).join("\n");
1238
+ }
1199
1239
  /**
1200
1240
  * Convert a container + children into the appropriate TreeNode(s).
1201
1241
  * Handles callout type aliases, tabs definition-list recovery, cards list form, etc.
@@ -1251,6 +1291,11 @@ function mapContainerToTreeNode(name, attrs, children) {
1251
1291
  "quote",
1252
1292
  "callout",
1253
1293
  ]);
1294
+ // `::math{latex="…"}` (the leaf in docs/dogsbay-md.md): the shape `$$…$$` has. As a `math`
1295
+ // block it was written back as nothing and drawn empty.
1296
+ if (name === "math" && typeof props.latex === "string" && children.length === 0) {
1297
+ return { type: "math-block", props: { latex: props.latex } };
1298
+ }
1254
1299
  if (CALLOUT_TYPES.has(name)) {
1255
1300
  const calloutProps = { ...props };
1256
1301
  if (name !== "callout")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/format-dogsbay-md",
3
- "version": "0.2.0-beta.142",
3
+ "version": "0.2.0-beta.144",
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/corpus": "0.2.0-beta.142",
44
- "@dogsbay/minja": "0.2.0-beta.142",
45
- "@dogsbay/types": "0.2.0-beta.142",
46
- "@dogsbay/serialize-core": "0.2.0-beta.142"
43
+ "@dogsbay/corpus": "0.2.0-beta.144",
44
+ "@dogsbay/minja": "0.2.0-beta.144",
45
+ "@dogsbay/serialize-core": "0.2.0-beta.144",
46
+ "@dogsbay/types": "0.2.0-beta.144"
47
47
  },
48
48
  "license": "MIT",
49
49
  "repository": {