@dogsbay/format-dogsbay-md 0.2.0-beta.137 → 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
@@ -10,6 +10,8 @@
10
10
  */
11
11
  import { BLOCK_LEAF_DIRECTIVES, GENERIC_CONTAINER } from "./plugin-containers.js";
12
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
  }
@@ -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
+ }
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";
@@ -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
@@ -1795,12 +1795,14 @@ function tokensToInline(tokens, inheritedFmt = []) {
1795
1795
  }
1796
1796
  const labelInline = tokensToInline(labelTokens);
1797
1797
  const labelText = inlineText(labelInline);
1798
- const labelless = tok.meta?.labelless === true;
1799
- let node = inlineDirectiveToNode(name, labelText, labelInline, attrs, labelless);
1800
- // An unknown label-less directive stays as written (`:name{…}`), as TEXT: its attrs may carry
1801
- // a tag, and html-inline is written raw by every consumer
1802
- if (node?.type === "html-inline" && labelless && node.html.startsWith(`:${name}[]`))
1803
- node = { type: "text", text: `:${name}${node.html.slice(name.length + 3)}` };
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 };
1804
1806
  if (node)
1805
1807
  result.push(node);
1806
1808
  break;
@@ -1,4 +1,4 @@
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
4
  /** The prop that labels a known inline directive written without `[text]` (`:kbd{value="Ctrl"}`). */
@@ -115,7 +115,11 @@ function inlineDirectiveRule(state, silent) {
115
115
  state.pos = pos;
116
116
  return true;
117
117
  }
118
- 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);
119
123
  const attrsStr = JSON.stringify(parsedAttrs);
120
124
  if (isLeaf) {
121
125
  const t = state.push("dogsbay_leaf_directive", "", 0);
@@ -126,8 +130,9 @@ function inlineDirectiveRule(state, silent) {
126
130
  const openT = state.push("dogsbay_inline_directive_open", "", 1);
127
131
  openT.tag = name;
128
132
  openT.info = attrsStr;
133
+ // the source as written: an unknown label-less directive stays these bytes, not a re-rendering
129
134
  if (labelless)
130
- openT.meta = { labelless: true };
135
+ openT.meta = { labelless: true, literal, source: src.slice(start, pos) };
131
136
  if (!labelless) {
132
137
  // Parse label as inline content (recursive)
133
138
  const oldPos = state.pos;
package/dist/serialize.js CHANGED
@@ -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}` : ">"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/format-dogsbay-md",
3
- "version": "0.2.0-beta.137",
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/corpus": "0.2.0-beta.137",
44
- "@dogsbay/minja": "0.2.0-beta.137",
45
- "@dogsbay/types": "0.2.0-beta.137",
46
- "@dogsbay/serialize-core": "0.2.0-beta.137"
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": {