@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 +1 -0
- package/dist/browser.js +1 -0
- package/dist/cli.d.ts +1 -18
- package/dist/cli.js +16 -111
- package/dist/nav.js +13 -1
- package/dist/project.d.ts +60 -0
- package/dist/project.js +109 -0
- package/dist/serialize.js +173 -15
- package/dist/yaml.d.ts +0 -3
- package/dist/yaml.js +27 -0
- package/package.json +5 -4
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 {
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
334
|
-
const
|
|
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
|
-
|
|
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
|
+
}[];
|
package/dist/project.js
ADDED
|
@@ -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 `{`; 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 `:`.
|
|
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, "{"));
|
|
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, ":")))
|
|
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
|
|
200
|
-
* blocks so an agent fetching the `.md` URL
|
|
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
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
44
|
-
"@dogsbay/serialize-core": "0.2.0-beta.
|
|
45
|
-
"@dogsbay/
|
|
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": {
|