@dogsbay/format-dogsbay-md 0.2.0-beta.131 → 0.2.0-beta.133

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
@@ -22,3 +22,4 @@ export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcess
22
22
  export { extractHeadings, slugifyHeadingText, type HeadingInfo } from "./headings.js";
23
23
  export { splitFrontmatter, type Frontmatter } from "./frontmatter.js";
24
24
  export { TemplatePages, TemplatePageError, declaresParams, ATTRIBUTE_BLOCK_OPEN, type TemplatePagesOptions } from "./template-pages.js";
25
+ export { dogsbayMdPageFiles, mountCopies, carriedFrontmatter, MEDIA_EXTS, type AssetMount } from "./project.js";
package/dist/browser.js CHANGED
@@ -22,3 +22,4 @@ export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcess
22
22
  export { extractHeadings, slugifyHeadingText } from "./headings.js";
23
23
  export { splitFrontmatter } from "./frontmatter.js";
24
24
  export { TemplatePages, TemplatePageError, declaresParams, ATTRIBUTE_BLOCK_OPEN } from "./template-pages.js";
25
+ export { dogsbayMdPageFiles, mountCopies, carriedFrontmatter, MEDIA_EXTS } from "./project.js";
package/dist/cli.d.ts CHANGED
@@ -1,20 +1,3 @@
1
1
  import type { FormatPlugin } from "@dogsbay/types";
2
- /**
3
- * The source page's front matter, carried into the Dogsbay MD it becomes.
4
- * It used to be DROPPED (only title and redirect were written), so a
5
- * converted corpus lost `description`, `tags` and custom fields, and a
6
- * query listing (`::child-links{match=…}`) over it found nothing.
7
- *
8
- * Keys Dogsbay MD gives meaning to keep their value only when the value
9
- * MEANS that here; otherwise the key moves under `imported:` — kept, never
10
- * misread. Starlight's `template: splash` (a page template) must not turn a
11
- * page into a Jinja template page. A Dogsbay MD source round-trips unchanged:
12
- * its values are valid by construction.
13
- *
14
- * `title`, `redirect` and the template keys are written by the caller.
15
- * `layout` is free-form (a theme layout name) and passes as it is.
16
- * `imported:`, not `source:` — Cloudflare pages carry their own `source:`
17
- * (an origin URL, 91 pages), which the moved keys would have overwritten.
18
- */
19
- export declare function carriedFrontmatter(fm: Record<string, unknown> | undefined): Record<string, unknown>;
2
+ export { carriedFrontmatter } from "./project.js";
20
3
  export declare const plugin: FormatPlugin;
package/dist/cli.js CHANGED
@@ -4,15 +4,17 @@
4
4
  * Reads a directory of .md files, parses each via dogsbayMdToTree, builds nav
5
5
  * from directory structure + frontmatter, returns ExportPage[] + NavItem[].
6
6
  */
7
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
8
8
  import { join, relative, basename, dirname } from "node:path";
9
9
  import { extractHeadings } from "./headings.js";
10
10
  import { parseMeta } from "@dogsbay/types";
11
11
  import { dogsbayMdToTree } from "./parse.js";
12
- import { treeToDogsbayMd } from "./serialize.js";
13
12
  import { buildNavFromDirectory } from "./nav.js";
14
13
  import { renderDiagrams } from "./renderers/diagrams.js";
15
14
  import { DOGSBAY_MD_CAPABILITIES } from "./capabilities.js";
15
+ import { dogsbayMdPageFiles, mountCopies } from "./project.js";
16
+ import { readFolder } from "@dogsbay/corpus/folder";
17
+ export { carriedFrontmatter } from "./project.js";
16
18
  /**
17
19
  * Detect whether a directory contains a Dogsbay MD source tree.
18
20
  *
@@ -221,79 +223,6 @@ function prettifyName(slug) {
221
223
  * is the include path. Mirrors migrate-asciidoc's fragment convention so the
222
224
  * build-time Minja preprocessor can `{% include %}` them.
223
225
  */
224
- /** Media files worth carrying into `_assets` from a media-only mount. */
225
- const MEDIA_EXTS = new Set([
226
- ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif",
227
- ".svg", ".ico", ".pdf", ".mp4", ".webm",
228
- ]);
229
- /** Recursively copy only media files, preserving relative structure. */
230
- function copyMediaTree(srcDir, destDir) {
231
- for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
232
- const full = join(srcDir, entry.name);
233
- if (entry.isDirectory()) {
234
- copyMediaTree(full, join(destDir, entry.name));
235
- continue;
236
- }
237
- const dot = entry.name.lastIndexOf(".");
238
- if (dot === -1 || !MEDIA_EXTS.has(entry.name.slice(dot).toLowerCase()))
239
- continue;
240
- mkdirSync(destDir, { recursive: true });
241
- cpSync(full, join(destDir, entry.name));
242
- }
243
- }
244
- /**
245
- * The source page's front matter, carried into the Dogsbay MD it becomes.
246
- * It used to be DROPPED (only title and redirect were written), so a
247
- * converted corpus lost `description`, `tags` and custom fields, and a
248
- * query listing (`::child-links{match=…}`) over it found nothing.
249
- *
250
- * Keys Dogsbay MD gives meaning to keep their value only when the value
251
- * MEANS that here; otherwise the key moves under `imported:` — kept, never
252
- * misread. Starlight's `template: splash` (a page template) must not turn a
253
- * page into a Jinja template page. A Dogsbay MD source round-trips unchanged:
254
- * its values are valid by construction.
255
- *
256
- * `title`, `redirect` and the template keys are written by the caller.
257
- * `layout` is free-form (a theme layout name) and passes as it is.
258
- * `imported:`, not `source:` — Cloudflare pages carry their own `source:`
259
- * (an origin URL, 91 pages), which the moved keys would have overwritten.
260
- */
261
- export function carriedFrontmatter(fm) {
262
- if (!fm)
263
- return {};
264
- const out = {};
265
- const source = {};
266
- const valid = {
267
- // `false` is the documented per-page opt-out of a source's `templates: jinja`.
268
- template: (v) => v === "jinja" || v === false,
269
- weight: (v) => typeof v === "number" && Number.isFinite(v),
270
- order: (v) => typeof v === "number" && Number.isFinite(v),
271
- draft: (v) => typeof v === "boolean",
272
- status: (v) => typeof v === "string" && ["draft", "preview", "stable", "deprecated"].includes(v),
273
- // `params:` declares a template PARTIAL's parameters: only with `template: jinja`.
274
- params: (v) => Array.isArray(v) && fm.template === "jinja",
275
- };
276
- for (const [key, value] of Object.entries(fm)) {
277
- if (key === "title" || key === "redirect" || value === undefined)
278
- continue;
279
- // A source that already has `imported:` keeps it: merged if a mapping,
280
- // else nested under its own name.
281
- if (key === "imported") {
282
- if (value && typeof value === "object" && !Array.isArray(value))
283
- Object.assign(source, value);
284
- else
285
- source.imported = value;
286
- continue;
287
- }
288
- if (valid[key] && !valid[key](value))
289
- source[key] = value;
290
- else
291
- out[key] = value;
292
- }
293
- if (Object.keys(source).length)
294
- out.imported = source;
295
- return out;
296
- }
297
226
  async function exportDogsbayMd(pages, nav, output, opts) {
298
227
  mkdirSync(output, { recursive: true });
299
228
  // Copy mounted asset dirs (e.g. Docusaurus `static/`) into `content/_assets/`,
@@ -303,51 +232,27 @@ async function exportDogsbayMd(pages, nav, output, opts) {
303
232
  // `content/_assets/**` through to `/_assets/**`. A mount may target an
304
233
  // `_assets` subdir (`into`) and restrict itself to media files (`mediaOnly`) —
305
234
  // used for docs-co-located images where the mount dir IS the docs tree.
235
+ // The copy is `mountCopies` over the mount read as a corpus — the list the browser import
236
+ // copies from its map, so both hosts copy the same files. A mount is someone else's repo:
237
+ // a symlink out of it (`public/leak.png -> ~/.ssh/id_rsa`) was copied as a link to that
238
+ // file, and `node_modules` / `.git` inside it were copied whole (2026-10-21).
306
239
  if (Array.isArray(opts.assetMounts)) {
307
240
  for (const mount of opts.assetMounts) {
308
241
  if (!existsSync(mount.dir))
309
242
  continue;
310
- // Per-mount root: `_assets` for an imported corpus, `images`/`assets`
311
- // for hand-authored content. A hardcoded root would copy the file
312
- // where the reference does not point.
313
- const assetsRoot = join(output, mount.root ?? "_assets");
314
- const dest = mount.into ? join(assetsRoot, mount.into) : assetsRoot;
315
- if (mount.mediaOnly) {
316
- copyMediaTree(mount.dir, dest);
317
- continue;
243
+ const folder = readFolder(mount.dir);
244
+ for (const { from, to } of mountCopies(folder.paths, [{ ...mount, dir: "" }])) {
245
+ const dst = join(output, to);
246
+ mkdirSync(dirname(dst), { recursive: true });
247
+ copyFileSync(folder.absolute(from), dst);
318
248
  }
319
- const skip = new Set(mount.exclude ?? []);
320
- cpSync(mount.dir, dest, {
321
- recursive: true,
322
- ...(skip.size === 0 ? {} : {
323
- // Relative to the mount, so an exclusion cannot be accidentally
324
- // satisfied by a directory name somewhere above it on disk.
325
- filter: (src) => {
326
- const rel = relative(mount.dir, src);
327
- return rel === "" || !rel.split(/[\\/]/).some((seg) => skip.has(seg));
328
- },
329
- }),
330
- });
331
249
  }
332
250
  }
333
- for (const page of pages) {
334
- const slug = page.slug || "index";
335
- const outPath = join(output, `${slug}.md`);
251
+ for (const [path, text] of dogsbayMdPageFiles(pages, nav)) {
252
+ const outPath = join(output, path);
336
253
  mkdirSync(dirname(outPath), { recursive: true });
337
- // A template partial keeps its `params:` and the template flag, so the
338
- // build treats it as a partial and checks every include against it.
339
- const templateFm = page.template ? { template: "jinja", ...(page.templateParams ? { params: page.templateParams } : {}) } : {};
340
- // A title-less template page still needs `template: jinja`, or the build
341
- // renders it in content mode and shows the directives as text.
342
- const frontmatter = page.fragment || !page.title
343
- ? (page.template ? templateFm : undefined)
344
- : { title: page.title, ...carriedFrontmatter(page.frontmatter), ...(page.redirect ? { redirect: page.redirect } : {}), ...templateFm };
345
- const md = treeToDogsbayMd(page.tree, frontmatter ? { frontmatter } : undefined);
346
- writeFileSync(outPath, md.endsWith("\n") ? md : md + "\n", "utf-8");
254
+ writeFileSync(outPath, text, "utf-8");
347
255
  }
348
- // Nav as nav.json at the content root (the dogsbay-md importer + runtime
349
- // loader both prefer nav.json). Fragments never appear here.
350
- writeFileSync(join(output, "nav.json"), JSON.stringify(nav, null, 2) + "\n", "utf-8");
351
256
  }
352
257
  export const plugin = {
353
258
  name: "dogsbay-md",
package/dist/nav.js CHANGED
@@ -14,6 +14,18 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
14
  import { join, relative, basename, dirname } from "node:path";
15
15
  import matter from "gray-matter";
16
16
  import { loadNavFile } from "./nav-file.js";
17
+ /**
18
+ * gray-matter EVALUATES front matter that opens with `---js` / `---javascript` (its javascript
19
+ * engine is `eval`), so a page in someone else's repo could run code on this machine. Every
20
+ * call passes this, which refuses that engine; YAML and JSON are unchanged.
21
+ */
22
+ const NO_JS_FRONT_MATTER = {
23
+ engines: {
24
+ javascript: () => {
25
+ throw new Error("JavaScript front matter is not run");
26
+ },
27
+ },
28
+ };
17
29
  /**
18
30
  * File names tried, in priority order, when auto-detecting a nav
19
31
  * file in a content directory. The first existing file wins.
@@ -85,7 +97,7 @@ function collectPages(dir, rootDir) {
85
97
  const content = readFileSync(fullPath, "utf-8");
86
98
  let frontmatter = {};
87
99
  try {
88
- frontmatter = (matter(content).data ?? {});
100
+ frontmatter = (matter(content, NO_JS_FRONT_MATTER).data ?? {});
89
101
  }
90
102
  catch {
91
103
  // Malformed frontmatter — treat as no frontmatter
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A converted corpus as a Dogsbay MD project, as data: what `convert --to dogsbay-md` writes
3
+ * (each page as `<slug>.md`, the nav as `nav.json`, the importer's asset mounts under
4
+ * `_assets/`), without the file system. The exporter writes these files; the playground's
5
+ * browser imports (`@dogsbay/format-vitepress/migrate`) return them, so a migration in the
6
+ * browser is the one `convert` does.
7
+ */
8
+ import type { ExportPage, NavItem } from "@dogsbay/types";
9
+ /** Media files worth carrying into `_assets` from a media-only mount. */
10
+ export declare const MEDIA_EXTS: Set<string>;
11
+ /**
12
+ * The source page's front matter, carried into the Dogsbay MD it becomes.
13
+ * It used to be DROPPED (only title and redirect were written), so a
14
+ * converted corpus lost `description`, `tags` and custom fields, and a
15
+ * query listing (`::child-links{match=…}`) over it found nothing.
16
+ *
17
+ * Keys Dogsbay MD gives meaning to keep their value only when the value
18
+ * MEANS that here; otherwise the key moves under `imported:` — kept, never
19
+ * misread. Starlight's `template: splash` (a page template) must not turn a
20
+ * page into a Jinja template page. A Dogsbay MD source round-trips unchanged:
21
+ * its values are valid by construction.
22
+ *
23
+ * `title`, `redirect` and the template keys are written by the caller.
24
+ * `layout` is free-form (a theme layout name) and passes as it is.
25
+ * `imported:`, not `source:` — Cloudflare pages carry their own `source:`
26
+ * (an origin URL, 91 pages), which the moved keys would have overwritten.
27
+ */
28
+ export declare function carriedFrontmatter(fm: Record<string, unknown> | undefined): Record<string, unknown>;
29
+ /** Each page as `<slug>.md` and the nav as `nav.json`, relative to the content root. */
30
+ export declare function dogsbayMdPageFiles(pages: ExportPage[], nav: NavItem[]): Map<string, string>;
31
+ /** An importer's asset directory, as `ImportResult.assetMounts` declares it, relative to the corpus. */
32
+ export interface AssetMount {
33
+ dir: string;
34
+ /** A subdirectory of the root to land in (`pages` → `_assets/pages/`). */
35
+ into?: string;
36
+ /** Media files only (`MEDIA_EXTS`): the mount is the docs tree itself. */
37
+ mediaOnly?: boolean;
38
+ /**
39
+ * Directory names to leave behind, matched at ANY depth in the mount.
40
+ *
41
+ * `mediaOnly` is the wrong tool when a mount is mostly wanted: it also drops scripts, YAML
42
+ * and text a corpus may legitimately carry. This skips named subtrees instead — e.g.
43
+ * Calico's `licenses/`, ~35 MB of third-party attribution HTML for versions the corpus does
44
+ * not even import, which nothing references. Matched at any depth on purpose: the first
45
+ * version compared only the FIRST path segment and silently did nothing, because the real
46
+ * tree is `_assets/calico/3.32/licenses/…`.
47
+ */
48
+ exclude?: string[];
49
+ /** Public root the mount lands in; `_assets` unless the source used another. */
50
+ root?: string;
51
+ }
52
+ /**
53
+ * The files the exporter copies for `mounts`, from a corpus's `paths`: corpus path → path in
54
+ * the content root. The same rules as the exporter's copy on disk: the whole mount minus
55
+ * excluded names, or its media only.
56
+ */
57
+ export declare function mountCopies(paths: string[], mounts: AssetMount[]): {
58
+ from: string;
59
+ to: string;
60
+ }[];
@@ -0,0 +1,109 @@
1
+ import { treeToDogsbayMd } from "./serialize.js";
2
+ /** Media files worth carrying into `_assets` from a media-only mount. */
3
+ export const MEDIA_EXTS = new Set([
4
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif",
5
+ ".svg", ".ico", ".pdf", ".mp4", ".webm",
6
+ ]);
7
+ /**
8
+ * The source page's front matter, carried into the Dogsbay MD it becomes.
9
+ * It used to be DROPPED (only title and redirect were written), so a
10
+ * converted corpus lost `description`, `tags` and custom fields, and a
11
+ * query listing (`::child-links{match=…}`) over it found nothing.
12
+ *
13
+ * Keys Dogsbay MD gives meaning to keep their value only when the value
14
+ * MEANS that here; otherwise the key moves under `imported:` — kept, never
15
+ * misread. Starlight's `template: splash` (a page template) must not turn a
16
+ * page into a Jinja template page. A Dogsbay MD source round-trips unchanged:
17
+ * its values are valid by construction.
18
+ *
19
+ * `title`, `redirect` and the template keys are written by the caller.
20
+ * `layout` is free-form (a theme layout name) and passes as it is.
21
+ * `imported:`, not `source:` — Cloudflare pages carry their own `source:`
22
+ * (an origin URL, 91 pages), which the moved keys would have overwritten.
23
+ */
24
+ export function carriedFrontmatter(fm) {
25
+ if (!fm)
26
+ return {};
27
+ const out = {};
28
+ const source = {};
29
+ const valid = {
30
+ // `false` is the documented per-page opt-out of a source's `templates: jinja`.
31
+ template: (v) => v === "jinja" || v === false,
32
+ weight: (v) => typeof v === "number" && Number.isFinite(v),
33
+ order: (v) => typeof v === "number" && Number.isFinite(v),
34
+ draft: (v) => typeof v === "boolean",
35
+ status: (v) => typeof v === "string" && ["draft", "preview", "stable", "deprecated"].includes(v),
36
+ // `params:` declares a template PARTIAL's parameters: only with `template: jinja`.
37
+ params: (v) => Array.isArray(v) && fm.template === "jinja",
38
+ };
39
+ for (const [key, value] of Object.entries(fm)) {
40
+ if (key === "title" || key === "redirect" || value === undefined)
41
+ continue;
42
+ // A source that already has `imported:` keeps it: merged if a mapping,
43
+ // else nested under its own name.
44
+ if (key === "imported") {
45
+ if (value && typeof value === "object" && !Array.isArray(value))
46
+ Object.assign(source, value);
47
+ else
48
+ source.imported = value;
49
+ continue;
50
+ }
51
+ if (valid[key] && !valid[key](value))
52
+ source[key] = value;
53
+ else
54
+ out[key] = value;
55
+ }
56
+ if (Object.keys(source).length)
57
+ out.imported = source;
58
+ return out;
59
+ }
60
+ /** Each page as `<slug>.md` and the nav as `nav.json`, relative to the content root. */
61
+ export function dogsbayMdPageFiles(pages, nav) {
62
+ const files = new Map();
63
+ for (const page of pages) {
64
+ const slug = page.slug || "index";
65
+ // A template partial keeps its `params:` and the template flag, so the
66
+ // build treats it as a partial and checks every include against it.
67
+ const templateFm = page.template ? { template: "jinja", ...(page.templateParams ? { params: page.templateParams } : {}) } : {};
68
+ // A title-less template page still needs `template: jinja`, or the build
69
+ // renders it in content mode and shows the directives as text.
70
+ const frontmatter = page.fragment || !page.title
71
+ ? (page.template ? templateFm : undefined)
72
+ : { title: page.title, ...carriedFrontmatter(page.frontmatter), ...(page.redirect ? { redirect: page.redirect } : {}), ...templateFm };
73
+ const md = treeToDogsbayMd(page.tree, frontmatter ? { frontmatter } : undefined);
74
+ files.set(`${slug}.md`, md.endsWith("\n") ? md : md + "\n");
75
+ }
76
+ // Nav as nav.json at the content root (the dogsbay-md importer + runtime
77
+ // loader both prefer nav.json). Fragments never appear here.
78
+ files.set("nav.json", JSON.stringify(nav, null, 2) + "\n");
79
+ return files;
80
+ }
81
+ /**
82
+ * The files the exporter copies for `mounts`, from a corpus's `paths`: corpus path → path in
83
+ * the content root. The same rules as the exporter's copy on disk: the whole mount minus
84
+ * excluded names, or its media only.
85
+ */
86
+ export function mountCopies(paths, mounts) {
87
+ const out = [];
88
+ for (const mount of mounts) {
89
+ const dir = mount.dir.replace(/\/+$/, "");
90
+ const dest = [mount.root ?? "_assets", mount.into].filter(Boolean).join("/");
91
+ const skip = new Set(mount.exclude ?? []);
92
+ for (const path of paths) {
93
+ if (dir && !path.startsWith(dir + "/"))
94
+ continue;
95
+ const rel = dir ? path.slice(dir.length + 1) : path;
96
+ const segs = rel.split("/");
97
+ if (mount.mediaOnly) {
98
+ const name = segs[segs.length - 1];
99
+ const dot = name.lastIndexOf(".");
100
+ if (dot === -1 || !MEDIA_EXTS.has(name.slice(dot).toLowerCase()))
101
+ continue;
102
+ }
103
+ else if (segs.some((s) => skip.has(s)))
104
+ continue;
105
+ out.push({ from: path, to: `${dest}/${rel}` });
106
+ }
107
+ }
108
+ return out;
109
+ }
package/dist/serialize.js CHANGED
@@ -1,4 +1,6 @@
1
- import { walkInline, applyTextFlags, renderAroundGroup } from "@dogsbay/serialize-core";
1
+ import { walkInline, applyTextFlags, renderAroundGroup, neutralizeMarkdown, mapOutsideCodeSpans } from "@dogsbay/serialize-core";
2
+ import MarkdownIt from "markdown-it";
3
+ import { transformFenceInfo, transformProseSegments } from "./code-regions.js";
2
4
  import { inlineToDogsbayMd } from "./inline.js";
3
5
  import { assertDirectiveField } from "./directive-guard.js";
4
6
  import { renderAttrs, splitProps } from "./attributes.js";
@@ -192,13 +194,60 @@ function renderConditionalDirective(node, ctx) {
192
194
  .join("\n\n");
193
195
  return body ? `${opener}\n\n${body}\n\n{% endif %}` : `${opener}\n\n{% endif %}`;
194
196
  }
197
+ /**
198
+ * Markdown we did not write (an OpenAPI description) kept as Markdown but not as Dogsbay MD.
199
+ * Each piece of syntax is made inert by the grammar of whatever would act on it, never by a
200
+ * guess at it (review 2026-10-21: three guesses disagreed with the readers, and template tags
201
+ * and containers leaked):
202
+ * - template tags, by minja's own code/prose split (`transformProseSegments`): `{` before
203
+ * `%`, `{` or `#` in what minja reads as prose becomes `&#123;`; and a fence opener's `{…}`
204
+ * attribute group is dropped wherever minja sees an opener (`transformFenceInfo`: indented,
205
+ * after a definition-list `:`) — `{minja}` there opts the block INTO template processing;
206
+ * - directives (`:::name`, `::name`, `:name[…]`), by markdown-it's: outside the lines it
207
+ * parses as code and outside code spans, every colon of a colon run and a colon that could
208
+ * start a directive becomes `&#58;`.
209
+ * Entities read the same as the characters; code is left as written (neither reader acts on
210
+ * it there).
211
+ */
212
+ function inertDogsbaySyntax(md) {
213
+ let out = transformFenceInfo(md, (info) => info.replace(/\s*\{[^{}]*\}[\s\S]*$/, ""));
214
+ out = transformProseSegments(out, (p) => p.replace(/\{(?=[%{#])/g, "&#123;"));
215
+ const code = new Set();
216
+ for (const t of CODE_LINES_PARSER.parse(out, {})) {
217
+ if ((t.type === "fence" || t.type === "code_block") && t.map)
218
+ for (let i = t.map[0]; i < t.map[1]; i++)
219
+ code.add(i);
220
+ }
221
+ return out
222
+ .split("\n")
223
+ .map((line, i) => code.has(i)
224
+ ? line
225
+ : mapOutsideCodeSpans(line, (p) => p.replace(/:(?=:)|(?<=:):|(?<![\p{L}\p{N}:]):(?=[A-Za-z])/gu, "&#58;")))
226
+ .join("\n");
227
+ }
228
+ /** Only to find the lines markdown-it reads as code (fences, indented blocks). */
229
+ const CODE_LINES_PARSER = new MarkdownIt();
230
+ /** How deep a schema is written, how many field lines an endpoint gets, how long an example is. */
231
+ const ENDPOINT_FIELD_DEPTH = 8;
232
+ const ENDPOINT_FIELD_LINES = 400;
233
+ const ENDPOINT_EXAMPLE_CHARS = 8000;
195
234
  /**
196
235
  * Serialize an OpenAPI `endpoint` TreeNode (produced by
197
236
  * @dogsbay/format-openapi) as agent-readable markdown. The HTML
198
237
  * version uses ApiLayout + EndpointCard + friends; the markdown
199
- * mirror collapses the same data into headings, tables, and code
200
- * blocks so an agent fetching the `.md` URL still gets the full
201
- * operation reference.
238
+ * mirror writes the same data as headings, tables, lists and code
239
+ * blocks so an agent fetching the `.md` URL (or a site converted to
240
+ * Dogsbay MD) gets the full operation reference: parameters with their
241
+ * enums and defaults, the request body's fields to any depth (dotted
242
+ * names: `user.address.city`, `items[].id`), and each response's
243
+ * content type, fields and example.
244
+ *
245
+ * The shapes are read back by `@dogsbay/transition`'s endpoint
246
+ * extractor: a `- \`name\` (\`type\`) …` line is a body field, a table
247
+ * headed Name / Type / Required is parameters, `- **200** — …` under
248
+ * `### Responses` is a response. Response fields are therefore a table
249
+ * headed FIELD, and body fields stay flat list lines (a nested list's
250
+ * text would run into its parent's).
202
251
  */
203
252
  function renderEndpoint(node) {
204
253
  const props = (node.props ?? {});
@@ -213,12 +262,96 @@ function renderEndpoint(node) {
213
262
  const responses = props.responses ?? [];
214
263
  const codeSamples = props.codeSamples ?? [];
215
264
  // Spec text is Markdown (CommonMark, per OpenAPI) from a file the site's
216
- // author may not have written: its Markdown is kept, its raw HTML is not
217
- // (`<` → `&lt;`, which still reads as `<`). Names and paths go into code
218
- // spans, which a backtick would end.
219
- const text = (v) => String(v ?? "").replace(/&(?=[#a-zA-Z0-9]+;)/g, "&amp;").replace(/</g, "&lt;");
220
- const line = (v) => text(v).replace(/\s*\n\s*/g, " ");
265
+ // author may not have written: its Markdown is kept; its raw HTML and
266
+ // unsafe links are not (neutralizeMarkdown), nor is Dogsbay MD's own
267
+ // syntax — a `:::` container or a `{% %}` / `{{ }}` / `{# #}` template
268
+ // tag would be read as one. Names and paths go into code spans, which a
269
+ // backtick would end.
270
+ const text = (v) => inertDogsbaySyntax(neutralizeMarkdown(String(v ?? "").replace(/\r\n?/g, "\n")));
271
+ // One line: joined BEFORE it is made inert (code that spans lines is prose once joined)
272
+ const line = (v) => text(String(v ?? "").replace(/\s*[\r\n]+\s*/g, " ").trim());
273
+ const cell = (v) => line(v).replace(/\|/g, "\\|");
221
274
  const code = (v) => String(v ?? "").replace(/[`\r\n]/g, " ");
275
+ /** A code span in a table cell: a `|` would end the cell. */
276
+ const codeCell = (v) => code(v).replace(/\|/g, "\\|");
277
+ const value = (v) => code(typeof v === "string" ? v : JSON.stringify(v));
278
+ /** The description, then what a reader needs to send a valid value. */
279
+ const detail = (p) => {
280
+ const parts = [];
281
+ if (p.description)
282
+ parts.push(line(p.description));
283
+ if (p.format)
284
+ parts.push(`Format: \`${code(p.format)}\`.`);
285
+ if (Array.isArray(p.enum) && p.enum.length) {
286
+ const shown = p.enum.slice(0, 20).map((e) => `\`${value(e)}\``).join(", ");
287
+ parts.push(`One of: ${shown}${p.enum.length > 20 ? `, … (${p.enum.length} values)` : ""}.`);
288
+ }
289
+ if (p.default !== undefined && p.default !== "")
290
+ parts.push(`Default: \`${value(p.default)}\`.`);
291
+ if (p.nullable === true)
292
+ parts.push("Nullable.");
293
+ if (p.deprecated === true)
294
+ parts.push("Deprecated.");
295
+ return parts.join(" ");
296
+ };
297
+ /** Field lines left for this endpoint: shared by the request body and every response. */
298
+ let fieldLines = ENDPOINT_FIELD_LINES;
299
+ /**
300
+ * Every field, flattened to dotted names, depth- and count-capped. `more` counts what is
301
+ * not written for either reason, descendants included (counting stops at 100,000).
302
+ */
303
+ const flatten = (fields) => {
304
+ const rows = [];
305
+ let more = 0;
306
+ const count = (list, depth) => {
307
+ if (!Array.isArray(list) || more >= 100_000 || depth > 64)
308
+ return;
309
+ for (const f of list) {
310
+ if (!f || typeof f !== "object")
311
+ continue;
312
+ more++;
313
+ count(f.properties, depth + 1);
314
+ count(f.items, depth + 1);
315
+ }
316
+ };
317
+ const walk = (list, prefix, depth) => {
318
+ if (!Array.isArray(list))
319
+ return;
320
+ if (depth > ENDPOINT_FIELD_DEPTH)
321
+ return count(list, depth);
322
+ for (const f of list) {
323
+ if (!f || typeof f !== "object")
324
+ continue;
325
+ if (fieldLines <= 0) {
326
+ count([f], depth);
327
+ continue;
328
+ }
329
+ fieldLines--;
330
+ const name = `${prefix}${String(f.name ?? "")}`;
331
+ rows.push({ name, field: f });
332
+ walk(f.properties, `${name}.`, depth + 1);
333
+ walk(f.items, `${name}[].`, depth + 1);
334
+ }
335
+ };
336
+ walk(fields, "", 0);
337
+ return { rows, more };
338
+ };
339
+ /**
340
+ * An example as a titled code block (JSON unless it is a string), cut at
341
+ * ENDPOINT_EXAMPLE_CHARS. The title, not a paragraph, says what it is: the endpoint
342
+ * extractor takes an endpoint's first paragraph as its description.
343
+ */
344
+ const example = (v, title, out) => {
345
+ if (v === undefined)
346
+ return;
347
+ let json = typeof v === "string" ? v : JSON.stringify(v, null, 2);
348
+ if (json === undefined)
349
+ return;
350
+ const cut = json.length > ENDPOINT_EXAMPLE_CHARS;
351
+ if (cut)
352
+ json = json.slice(0, ENDPOINT_EXAMPLE_CHARS);
353
+ out.push(renderCode({ type: "code", props: { code: json, language: typeof v === "string" ? "" : "json", title: cut ? `${title} (first ${ENDPOINT_EXAMPLE_CHARS} characters)` : title } }), "");
354
+ };
222
355
  const out = [];
223
356
  out.push(`## ${method.replace(/[^A-Z]/g, "")} ${line(path)}`);
224
357
  out.push("");
@@ -245,18 +378,23 @@ function renderEndpoint(node) {
245
378
  out.push("| Name | In | Type | Required | Description |");
246
379
  out.push("| --- | --- | --- | --- | --- |");
247
380
  for (const p of parameters) {
248
- const cell = (v) => line(v).replace(/\|/g, "\\|");
249
- out.push(`| \`${code(p.name)}\` | ${cell(p.in)} | \`${code(p.type)}\` | ${p.required ? "yes" : "no"} | ${cell(p.description)} |`);
381
+ out.push(`| \`${codeCell(p.name)}\` | ${cell(p.in)} | \`${codeCell(p.type)}\` | ${p.required ? "yes" : "no"} | ${detail(p).replace(/\|/g, "\\|")} |`);
250
382
  }
251
383
  out.push("");
252
384
  }
253
- if (requestBody.length > 0) {
385
+ if (requestBody.length > 0 || props.requestBodyExample !== undefined) {
254
386
  out.push("### Request body");
255
387
  out.push("");
256
- for (const p of requestBody) {
257
- out.push(`- \`${code(p.name)}\` (\`${code(p.type)}\`)${p.required ? " — required" : ""}${p.description ? ` — ${line(p.description)}` : ""}`);
388
+ const { rows, more } = flatten(requestBody);
389
+ for (const { name, field } of rows) {
390
+ const d = detail(field);
391
+ out.push(`- \`${code(name)}\` (\`${code(field.type)}\`)${field.required ? " — required" : ""}${d ? ` — ${d}` : ""}`);
258
392
  }
259
- out.push("");
393
+ if (more)
394
+ out.push(`- … ${more} more field${more === 1 ? "" : "s"} not shown`);
395
+ if (rows.length || more)
396
+ out.push("");
397
+ example(props.requestBodyExample, "Request example", out);
260
398
  }
261
399
  if (responses.length > 0) {
262
400
  out.push("### Responses");
@@ -267,6 +405,26 @@ function renderEndpoint(node) {
267
405
  out.push(`- **${status}** — ${desc}`);
268
406
  }
269
407
  out.push("");
408
+ for (const r of responses) {
409
+ const schema = Array.isArray(r.schema) ? r.schema : [];
410
+ if (!schema.length && r.example === undefined)
411
+ continue;
412
+ // The content type in the heading, not a paragraph (see `example`)
413
+ out.push(`#### ${line(r.status)}${r.contentType ? ` (\`${code(r.contentType)}\`)` : ""}`);
414
+ out.push("");
415
+ const { rows, more } = flatten(schema);
416
+ if (rows.length) {
417
+ out.push("| Field | Type | Required | Description |");
418
+ out.push("| --- | --- | --- | --- |");
419
+ for (const { name, field } of rows) {
420
+ out.push(`| \`${codeCell(name)}\` | \`${codeCell(field.type)}\` | ${field.required ? "yes" : "no"} | ${detail(field).replace(/\|/g, "\\|")} |`);
421
+ }
422
+ out.push("");
423
+ }
424
+ if (more)
425
+ out.push(`… ${more} more field${more === 1 ? "" : "s"} not shown.`, "");
426
+ example(r.example, `Response example (${line(r.status)})`, out);
427
+ }
270
428
  }
271
429
  if (codeSamples.length > 0) {
272
430
  out.push("### Code samples");
package/dist/yaml.d.ts CHANGED
@@ -15,7 +15,4 @@ export type YamlValue = string | number | boolean | null | undefined | Date | Ya
15
15
  * Keys emitted in insertion order.
16
16
  */
17
17
  export declare function renderYaml(value: Record<string, YamlValue>, indent?: number): string;
18
- /**
19
- * Render a frontmatter block: --- YAML ---
20
- */
21
18
  export declare function renderFrontmatter(value: Record<string, YamlValue>): string;
package/dist/yaml.js CHANGED
@@ -131,7 +131,34 @@ function needsQuoting(value) {
131
131
  /**
132
132
  * Render a frontmatter block: --- YAML ---
133
133
  */
134
+ /** Values a front matter may expand to when written out in full (see renderFrontmatter). */
135
+ const MAX_FRONTMATTER_VALUES = 100_000;
136
+ /** 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) {
138
+ if (v === null || typeof v !== "object" || v instanceof Date)
139
+ return 1;
140
+ const known = memo.get(v);
141
+ if (known !== undefined)
142
+ return known;
143
+ if (depth > 1000)
144
+ return cap + 1;
145
+ let n = 1;
146
+ for (const c of Array.isArray(v) ? v : Object.values(v)) {
147
+ n += expandedSize(c, cap, memo, depth + 1);
148
+ if (n > cap)
149
+ break;
150
+ }
151
+ memo.set(v, n);
152
+ return n;
153
+ }
134
154
  export function renderFrontmatter(value) {
155
+ // A source's YAML aliases are shared references once parsed. Written out in full, ten lines of
156
+ // nested aliases became megabytes (an alias bomb — review 2026-10-21, Docusaurus front matter;
157
+ // GitBook met it 2026-10-20). Such a value is written with its aliases kept; every other page
158
+ // is written exactly as before.
159
+ if (expandedSize(value, MAX_FRONTMATTER_VALUES) > MAX_FRONTMATTER_VALUES) {
160
+ return `---\n${jsyaml.dump(value, { lineWidth: -1, noRefs: false, skipInvalid: true }).trimEnd()}\n---`;
161
+ }
135
162
  const yaml = renderYaml(value);
136
163
  if (readsBackAs(yaml, value))
137
164
  return `---\n${yaml}\n---`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/format-dogsbay-md",
3
- "version": "0.2.0-beta.131",
3
+ "version": "0.2.0-beta.133",
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,9 +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.131",
44
- "@dogsbay/serialize-core": "0.2.0-beta.131",
45
- "@dogsbay/types": "0.2.0-beta.131"
43
+ "@dogsbay/corpus": "0.2.0-beta.133",
44
+ "@dogsbay/serialize-core": "0.2.0-beta.133",
45
+ "@dogsbay/minja": "0.2.0-beta.133",
46
+ "@dogsbay/types": "0.2.0-beta.133"
46
47
  },
47
48
  "license": "MIT",
48
49
  "repository": {