@dogsbay/format-mkdocs 0.2.0-beta.14 → 0.2.0-beta.140

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.
Files changed (147) hide show
  1. package/dist/cli.d.ts +0 -1
  2. package/dist/cli.js +115 -5
  3. package/dist/export/capabilities.d.ts +22 -0
  4. package/dist/export/capabilities.js +213 -0
  5. package/dist/export/to-astro.d.ts +0 -1
  6. package/dist/export/to-astro.js +12 -4
  7. package/dist/export/to-mkdocs-project.d.ts +12 -1
  8. package/dist/export/to-mkdocs-project.js +279 -64
  9. package/dist/export/to-mkdocs.d.ts +53 -4
  10. package/dist/export/to-mkdocs.js +905 -104
  11. package/dist/importer.d.ts +0 -1
  12. package/dist/importer.js +3 -2
  13. package/dist/index.d.ts +21 -4
  14. package/dist/index.js +40 -4
  15. package/dist/inline-walker.d.ts +0 -1
  16. package/dist/inline-walker.js +0 -1
  17. package/dist/loader.d.ts +1 -2
  18. package/dist/loader.js +28 -9
  19. package/dist/parse.d.ts +0 -1
  20. package/dist/parse.js +0 -1
  21. package/dist/renderers/mermaid.d.ts +0 -1
  22. package/dist/renderers/mermaid.js +0 -1
  23. package/dist/rules/admonition.d.ts +0 -1
  24. package/dist/rules/admonition.js +0 -1
  25. package/dist/rules/annotations.d.ts +0 -1
  26. package/dist/rules/annotations.js +0 -1
  27. package/dist/rules/autodoc.d.ts +1 -2
  28. package/dist/rules/autodoc.js +1 -3
  29. package/dist/rules/blocks.d.ts +0 -1
  30. package/dist/rules/blocks.js +0 -1
  31. package/dist/rules/content-tabs.d.ts +0 -1
  32. package/dist/rules/content-tabs.js +0 -1
  33. package/dist/rules/docusaurus-admonitions.d.ts +0 -1
  34. package/dist/rules/docusaurus-admonitions.js +0 -1
  35. package/dist/rules/file-include.d.ts +0 -1
  36. package/dist/rules/file-include.js +0 -1
  37. package/dist/rules/footnotes.d.ts +0 -1
  38. package/dist/rules/footnotes.js +0 -1
  39. package/dist/rules/icon-shortcode.d.ts +0 -1
  40. package/dist/rules/icon-shortcode.js +0 -1
  41. package/dist/rules/keys.d.ts +0 -1
  42. package/dist/rules/keys.js +0 -1
  43. package/dist/rules/link-rewrite.d.ts +0 -1
  44. package/dist/rules/link-rewrite.js +0 -1
  45. package/dist/rules/math.d.ts +0 -1
  46. package/dist/rules/math.js +0 -1
  47. package/dist/rules/md-in-html.d.ts +0 -1
  48. package/dist/rules/md-in-html.js +0 -1
  49. package/dist/rules/snippets.d.ts +0 -1
  50. package/dist/rules/snippets.js +0 -1
  51. package/dist/rules/templates.d.ts +0 -1
  52. package/dist/rules/templates.js +0 -1
  53. package/dist/rules/variants.d.ts +0 -1
  54. package/dist/rules/variants.js +0 -1
  55. package/dist/tree.d.ts +0 -1
  56. package/dist/tree.js +0 -1
  57. package/dist/utils/indent.d.ts +0 -1
  58. package/dist/utils/indent.js +0 -1
  59. package/dist/utils/types.d.ts +0 -1
  60. package/dist/utils/types.js +0 -1
  61. package/dist/walker.d.ts +0 -1
  62. package/dist/walker.js +0 -1
  63. package/package.json +13 -12
  64. package/dist/autodoc/markdown-generator.d.ts +0 -6
  65. package/dist/autodoc/markdown-generator.d.ts.map +0 -1
  66. package/dist/autodoc/markdown-generator.js +0 -242
  67. package/dist/autodoc/markdown-generator.js.map +0 -1
  68. package/dist/autodoc/module-resolver.d.ts +0 -8
  69. package/dist/autodoc/module-resolver.d.ts.map +0 -1
  70. package/dist/autodoc/module-resolver.js +0 -218
  71. package/dist/autodoc/module-resolver.js.map +0 -1
  72. package/dist/autodoc/python-docstring.d.ts +0 -7
  73. package/dist/autodoc/python-docstring.d.ts.map +0 -1
  74. package/dist/autodoc/python-docstring.js +0 -324
  75. package/dist/autodoc/python-docstring.js.map +0 -1
  76. package/dist/autodoc/python-parser.d.ts +0 -10
  77. package/dist/autodoc/python-parser.d.ts.map +0 -1
  78. package/dist/autodoc/python-parser.js +0 -541
  79. package/dist/autodoc/python-parser.js.map +0 -1
  80. package/dist/autodoc/tree-builder.d.ts +0 -9
  81. package/dist/autodoc/tree-builder.d.ts.map +0 -1
  82. package/dist/autodoc/tree-builder.js +0 -279
  83. package/dist/autodoc/tree-builder.js.map +0 -1
  84. package/dist/autodoc/types.d.ts +0 -128
  85. package/dist/autodoc/types.d.ts.map +0 -1
  86. package/dist/autodoc/types.js +0 -2
  87. package/dist/autodoc/types.js.map +0 -1
  88. package/dist/cli.d.ts.map +0 -1
  89. package/dist/cli.js.map +0 -1
  90. package/dist/export/to-astro.d.ts.map +0 -1
  91. package/dist/export/to-astro.js.map +0 -1
  92. package/dist/export/to-mkdocs-project.d.ts.map +0 -1
  93. package/dist/export/to-mkdocs-project.js.map +0 -1
  94. package/dist/export/to-mkdocs.d.ts.map +0 -1
  95. package/dist/export/to-mkdocs.js.map +0 -1
  96. package/dist/importer.d.ts.map +0 -1
  97. package/dist/importer.js.map +0 -1
  98. package/dist/index.d.ts.map +0 -1
  99. package/dist/index.js.map +0 -1
  100. package/dist/inline-walker.d.ts.map +0 -1
  101. package/dist/inline-walker.js.map +0 -1
  102. package/dist/loader.d.ts.map +0 -1
  103. package/dist/loader.js.map +0 -1
  104. package/dist/parse.d.ts.map +0 -1
  105. package/dist/parse.js.map +0 -1
  106. package/dist/renderers/mermaid.d.ts.map +0 -1
  107. package/dist/renderers/mermaid.js.map +0 -1
  108. package/dist/rules/admonition.d.ts.map +0 -1
  109. package/dist/rules/admonition.js.map +0 -1
  110. package/dist/rules/annotations.d.ts.map +0 -1
  111. package/dist/rules/annotations.js.map +0 -1
  112. package/dist/rules/autodoc.d.ts.map +0 -1
  113. package/dist/rules/autodoc.js.map +0 -1
  114. package/dist/rules/blocks.d.ts.map +0 -1
  115. package/dist/rules/blocks.js.map +0 -1
  116. package/dist/rules/content-tabs.d.ts.map +0 -1
  117. package/dist/rules/content-tabs.js.map +0 -1
  118. package/dist/rules/docusaurus-admonitions.d.ts.map +0 -1
  119. package/dist/rules/docusaurus-admonitions.js.map +0 -1
  120. package/dist/rules/file-include.d.ts.map +0 -1
  121. package/dist/rules/file-include.js.map +0 -1
  122. package/dist/rules/footnotes.d.ts.map +0 -1
  123. package/dist/rules/footnotes.js.map +0 -1
  124. package/dist/rules/icon-shortcode.d.ts.map +0 -1
  125. package/dist/rules/icon-shortcode.js.map +0 -1
  126. package/dist/rules/keys.d.ts.map +0 -1
  127. package/dist/rules/keys.js.map +0 -1
  128. package/dist/rules/link-rewrite.d.ts.map +0 -1
  129. package/dist/rules/link-rewrite.js.map +0 -1
  130. package/dist/rules/math.d.ts.map +0 -1
  131. package/dist/rules/math.js.map +0 -1
  132. package/dist/rules/md-in-html.d.ts.map +0 -1
  133. package/dist/rules/md-in-html.js.map +0 -1
  134. package/dist/rules/snippets.d.ts.map +0 -1
  135. package/dist/rules/snippets.js.map +0 -1
  136. package/dist/rules/templates.d.ts.map +0 -1
  137. package/dist/rules/templates.js.map +0 -1
  138. package/dist/rules/variants.d.ts.map +0 -1
  139. package/dist/rules/variants.js.map +0 -1
  140. package/dist/tree.d.ts.map +0 -1
  141. package/dist/tree.js.map +0 -1
  142. package/dist/utils/indent.d.ts.map +0 -1
  143. package/dist/utils/indent.js.map +0 -1
  144. package/dist/utils/types.d.ts.map +0 -1
  145. package/dist/utils/types.js.map +0 -1
  146. package/dist/walker.d.ts.map +0 -1
  147. package/dist/walker.js.map +0 -1
@@ -1,26 +1,116 @@
1
- import { treeToMkdocs } from "./to-mkdocs.js";
1
+ import { ExportLedger, frontmatterBlock, yamlScalar } from "@dogsbay/serialize-core";
2
+ import { treeToMkdocs, hrefToSlugKey } from "./to-mkdocs.js";
2
3
  /**
3
4
  * Export a set of pages + navigation to a complete MkDocs project.
4
5
  * Returns file map (path → content) including mkdocs.yml and all .md files.
5
6
  */
6
7
  export function exportMkdocsProject(pages, nav, options) {
7
8
  const files = new Map();
9
+ const ledger = new ExportLedger();
10
+ const slugs = new Set(pages.map((p) => p.slug));
8
11
  // Generate markdown files
9
12
  const allTrees = [];
10
13
  for (const page of pages) {
11
- const md = treeToMkdocs(page.tree);
12
- const filePath = `docs/${page.slug}.md`;
13
- files.set(filePath, md);
14
+ ledger.setPage(page.slug);
15
+ // A slug is a path under docs/. One that climbs out of it (`../x`),
16
+ // starts at the root, or carries a backslash is not a page name, it is a
17
+ // write outside the output tree. Refuse it, on the record.
18
+ if (!isSafeSlug(page.slug)) {
19
+ ledger.record({ feature: "page", action: "dropped", detail: `unsafe slug ${JSON.stringify(page.slug)} — not written` });
20
+ continue;
21
+ }
22
+ const path = `docs/${page.slug}.md`;
23
+ // Two pages on one slug (the importer strips `/index`, so `a.md` and
24
+ // `a/index.md` both arrive as `a`) used to overwrite silently — last one
25
+ // won, the ledger stayed empty, and the page count said 4 while 3 files
26
+ // existed. Keep the first, record the second.
27
+ if (files.has(path)) {
28
+ ledger.record({ feature: "page", action: "dropped", detail: `duplicate slug ${page.slug} — a second page with this slug was not written` });
29
+ continue;
30
+ }
31
+ const body = treeToMkdocs(page.tree, { pageSlug: page.slug, slugs, ledger });
32
+ // MkDocs reads `title:` from page meta; without it the nav label falls back
33
+ // to the first heading (fine) or the filename (not fine).
34
+ const fm = pageFrontmatter(page, ledger);
35
+ files.set(path, fm ? `${fm}\n\n${body}\n` : `${body}\n`);
14
36
  allTrees.push(page.tree);
15
37
  }
38
+ ledger.setPage(undefined);
16
39
  // Auto-detect required extensions from tree content
17
40
  const extensions = detectExtensions(allTrees, options.extraExtensions);
41
+ // Resolve nav against what was WRITTEN, not what was submitted: a page
42
+ // refused above (unsafe slug, duplicate) is still in `slugs`, and a nav
43
+ // entry or the home redirect pointing at it would be a link to a 404.
44
+ const written = new Set([...files.keys()].filter((k) => k.startsWith("docs/") && k.endsWith(".md")).map((k) => k.slice(5, -3)));
18
45
  // Convert nav to mkdocs.yml nav format
19
- const navYaml = navToYaml(nav, pages);
46
+ const navYaml = navToYaml(nav, written, ledger);
47
+ // Nothing at `/`. MkDocs serves `docs/index.md` as the site root and
48
+ // emits NOTHING there otherwise — no 404, no redirect, just no
49
+ // `site/index.html` — so a corpus whose nav starts at `welcome.md` (the
50
+ // OpenShift shape) published a site whose front door was a 404. The
51
+ // Docusaurus exporter closes the same hole with `src/pages/index.js`; this
52
+ // is the MkDocs equivalent: a meta-refresh to the first nav page. Kept out
53
+ // of `nav:` (MkDocs builds unlisted pages without listing them) and out of
54
+ // search.
55
+ if (!files.has("docs/index.md")) {
56
+ const first = firstNavSlug(nav, written);
57
+ if (first) {
58
+ files.set("docs/index.md", homeRedirect(options.siteName, first));
59
+ }
60
+ else {
61
+ ledger.record({ feature: "home-page", action: "dropped", detail: "no index page and no nav entry to redirect to — the site root will 404" });
62
+ }
63
+ }
20
64
  // Generate mkdocs.yml
21
65
  const yml = buildMkdocsYml(options, extensions, navYaml);
22
66
  files.set("mkdocs.yml", yml);
23
- return { files, mkdocsYml: yml };
67
+ return { files, mkdocsYml: yml, ledger };
68
+ }
69
+ function isSafeSlug(slug) {
70
+ if (!slug || slug.startsWith("/") || slug.includes("\\"))
71
+ return false;
72
+ return !slug.split("/").some((seg) => seg === ".." || seg === "." || seg === "");
73
+ }
74
+ /**
75
+ * Frontmatter keys MkDocs Material reads from page meta. Anything else the
76
+ * source carried is recorded as dropped rather than silently discarded — a
77
+ * mkdocs → mkdocs round trip that loses `tags:` looks like success otherwise.
78
+ */
79
+ const MATERIAL_META = new Set(["tags", "hide", "status", "template", "icon", "search", "subtitle"]);
80
+ function pageFrontmatter(page, ledger) {
81
+ const fm = (page.frontmatter ?? {});
82
+ const entries = [];
83
+ const rawTitle = fm.title ?? page.title;
84
+ const title = typeof rawTitle === "string" ? rawTitle : page.title;
85
+ if (title)
86
+ entries.push(["title", title]);
87
+ if (typeof fm.description === "string")
88
+ entries.push(["description", fm.description]);
89
+ const extra = [];
90
+ for (const [key, value] of Object.entries(fm)) {
91
+ if (key === "title" || key === "description")
92
+ continue;
93
+ if (MATERIAL_META.has(key)) {
94
+ if (Array.isArray(value) && value.every((v) => typeof v === "string" || typeof v === "number")) {
95
+ extra.push(`${key}:\n${value.map((v) => ` - ${yamlScalar(String(v))}`).join("\n")}`);
96
+ }
97
+ else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
98
+ extra.push(`${key}: ${typeof value === "string" ? yamlScalar(value) : String(value)}`);
99
+ }
100
+ else {
101
+ ledger.record({ feature: `frontmatter:${key}`, action: "dropped", detail: "unsupported value shape" });
102
+ }
103
+ continue;
104
+ }
105
+ ledger.record({ feature: `frontmatter:${key}`, action: "dropped", detail: "no MkDocs equivalent" });
106
+ }
107
+ if (entries.length === 0 && extra.length === 0)
108
+ return "";
109
+ const base = entries.length ? frontmatterBlock(entries) : "";
110
+ if (!extra.length)
111
+ return base;
112
+ const inner = base ? base.replace(/\n---$/, "") : "---";
113
+ return `${inner}\n${extra.join("\n")}\n---`;
24
114
  }
25
115
  // ── Extension Detection ─────────────────────────────────────────
26
116
  /**
@@ -29,8 +119,12 @@ export function exportMkdocsProject(pages, nav, options) {
29
119
  */
30
120
  function detectExtensions(allTrees, extra) {
31
121
  const found = new Set();
32
- // Always include these basics
122
+ // Always-on. `attr_list` because every heading carries `{ #id }`; the rest
123
+ // are cheap and what the serializer emits assumes them.
33
124
  found.add("toc:\n permalink: true");
125
+ found.add("attr_list");
126
+ found.add("md_in_html");
127
+ found.add("tables");
34
128
  for (const tree of allTrees) {
35
129
  walkForExtensions(tree, found);
36
130
  }
@@ -53,40 +147,92 @@ function walkForExtensions(nodes, found) {
53
147
  case "tabs":
54
148
  case "tab":
55
149
  found.add("pymdownx.tabbed:\n alternate_style: true");
150
+ found.add("pymdownx.superfences");
56
151
  break;
57
- case "code": {
152
+ case "code":
58
153
  found.add("pymdownx.highlight:\n anchor_linenums: true");
59
154
  found.add("pymdownx.superfences");
60
- if (node.props?.hasAnnotations) {
61
- found.add("pymdownx.superfences");
62
- }
63
155
  break;
64
- }
156
+ case "diagram":
157
+ found.add("pymdownx.superfences:\n custom_fences:\n - name: mermaid\n class: mermaid\n format: !!python/name:pymdownx.superfences.fence_code_format");
158
+ break;
65
159
  case "deflist":
66
160
  case "dt":
67
161
  case "dd":
68
162
  found.add("def_list");
69
163
  break;
70
- case "html-container":
71
- found.add("md_in_html");
72
- break;
73
164
  case "task-list":
74
165
  case "task-item":
75
166
  found.add("pymdownx.tasklist:\n custom_checkbox: true");
76
167
  break;
168
+ // Checkboxes are `list-item` nodes with `props.checked` — the shape the
169
+ // parser emits and the serializer reads; there is no `task-item` node.
170
+ case "list-item":
171
+ if (node.props?.checked !== undefined)
172
+ found.add("pymdownx.tasklist:\n custom_checkbox: true");
173
+ break;
77
174
  case "math":
175
+ case "math-block":
78
176
  case "math-inline":
79
177
  found.add("pymdownx.arithmatex:\n generic: true");
80
178
  break;
81
179
  case "footnote":
82
- case "footnote-ref":
180
+ case "footnote-list":
181
+ case "footnote-def":
83
182
  found.add("footnotes");
84
183
  break;
184
+ // The serializer emits `:octicons-arrow-right-24:` on a card link.
185
+ case "card":
186
+ case "cards":
187
+ case "link-card":
188
+ found.add(EMOJI_EXT);
189
+ break;
190
+ // An unrealized include is emitted as `--8<-- "path"`.
191
+ case "include":
192
+ found.add("pymdownx.snippets");
193
+ break;
85
194
  }
195
+ if (node.inline)
196
+ walkInlineForExtensions(node.inline, found);
86
197
  if (node.children)
87
198
  walkForExtensions(node.children, found);
88
199
  }
89
200
  }
201
+ const EMOJI_EXT = "pymdownx.emoji:\n emoji_index: !!python/name:material.extensions.emoji.twemoji\n emoji_generator: !!python/name:material.extensions.emoji.to_svg";
202
+ /**
203
+ * Inline nodes NEST — a highlight inside a link, a struck span inside bold.
204
+ * Walking one level detected `==x==` at top level and missed the same mark
205
+ * one link deeper, so the extension was absent and the marks rendered as
206
+ * literal `==`.
207
+ */
208
+ function walkInlineForExtensions(nodes, found) {
209
+ for (const inline of nodes) {
210
+ switch (inline.type) {
211
+ case "footnote-ref":
212
+ found.add("footnotes");
213
+ break;
214
+ case "highlight":
215
+ found.add("pymdownx.mark");
216
+ break;
217
+ case "kbd":
218
+ found.add("pymdownx.keys");
219
+ break;
220
+ case "math":
221
+ found.add("pymdownx.arithmatex:\n generic: true");
222
+ break;
223
+ case "icon":
224
+ found.add(EMOJI_EXT);
225
+ break;
226
+ case "text":
227
+ if (inline.strikethrough)
228
+ found.add("pymdownx.tilde");
229
+ break;
230
+ }
231
+ const kids = inline.children;
232
+ if (kids)
233
+ walkInlineForExtensions(kids, found);
234
+ }
235
+ }
90
236
  // ── Nav Conversion ──────────────────────────────────────────────
91
237
  /**
92
238
  * Convert NavItem[] to mkdocs.yml nav YAML string.
@@ -95,82 +241,152 @@ function walkForExtensions(nodes, found) {
95
241
  * - Home: index.md
96
242
  * - Section:
97
243
  * - Page: path.md
244
+ *
245
+ * An href is matched EXACTLY against the exported slugs (after stripping the
246
+ * origin, query, fragment and slashes). The previous last-segment fuzzy match
247
+ * would map `/installing/index/` to whichever page's slug ended in `index`
248
+ * first — wrong for any corpus with more than one index page.
98
249
  */
99
- function navToYaml(items, pages, indent = 2) {
250
+ function navToYaml(items, slugs, ledger, indent = 2) {
100
251
  const lines = [];
101
252
  const prefix = " ".repeat(indent);
102
253
  for (const item of items) {
103
- if (item.children && item.children.length > 0) {
104
- // Group with children
105
- if (item.href) {
106
- // Group has its own page + children
107
- lines.push(`${prefix}- ${item.label}:`);
108
- const slug = hrefToSlug(item.href, pages);
109
- if (slug) {
110
- lines.push(`${prefix} - ${item.label}: ${slug}.md`);
111
- }
112
- lines.push(navToYaml(item.children, pages, indent + 4));
113
- }
114
- else {
115
- // Group without own page
116
- lines.push(`${prefix}- ${item.label}:`);
117
- lines.push(navToYaml(item.children, pages, indent + 4));
118
- }
254
+ const label = yamlScalar(item.label ?? "");
255
+ const target = item.href ? navTarget(item.href, slugs) : null;
256
+ // Children FIRST, so a group whose entries all failed to resolve is
257
+ // skipped rather than emitted as `- Group:` — a null section, which is a
258
+ // hard `mkdocs build` configuration abort, not a warning.
259
+ const inner = item.children?.length ? navToYaml(item.children, slugs, ledger, indent + 4) : "";
260
+ if (item.href && !target) {
261
+ ledger.record({ feature: "nav-entry", action: "dropped", detail: `${item.label ?? ""} → ${item.href}: not an exported page` });
119
262
  }
120
- else if (item.href) {
121
- // Leaf page
122
- const slug = hrefToSlug(item.href, pages);
123
- if (slug) {
124
- lines.push(`${prefix}- ${item.label}: ${slug}.md`);
125
- }
263
+ if (inner) {
264
+ lines.push(`${prefix}- ${label}:`);
265
+ if (target)
266
+ lines.push(`${prefix} - ${label}: ${target}`);
267
+ lines.push(inner);
268
+ }
269
+ else if (target) {
270
+ lines.push(`${prefix}- ${label}: ${target}`);
271
+ }
272
+ else if (item.children?.length) {
273
+ ledger.record({ feature: "nav-entry", action: "dropped", detail: `${item.label ?? ""}: section with no exported pages` });
126
274
  }
127
275
  }
128
276
  return lines.filter(Boolean).join("\n");
129
277
  }
130
278
  /**
131
- * Convert an href ("/mkdocs/configuring") to a slug ("configuring")
132
- * by matching against known pages.
279
+ * What a nav href becomes in mkdocs.yml: a `slug.md` for an exported page,
280
+ * the URL itself for an external link (MkDocs supports `- Label: https://…`
281
+ * natively), or null when it names nothing we emit.
133
282
  */
134
- function hrefToSlug(href, pages) {
135
- // Strip leading path prefix — take the last segment(s)
136
- const parts = href.replace(/^\//, "").split("/");
137
- // Try matching against page slugs
138
- for (const page of pages) {
139
- // Exact match on slug
140
- if (parts.join("/") === page.slug || parts.slice(1).join("/") === page.slug) {
141
- return page.slug;
283
+ function navTarget(href, slugs) {
284
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//"))
285
+ return href;
286
+ const slug = hrefToSlug(href, slugs);
287
+ return slug ? `${slug}.md` : null;
288
+ }
289
+ function hrefToSlug(href, slugs) {
290
+ const key = hrefToSlugKey(href);
291
+ if (slugs.has(key))
292
+ return key;
293
+ if (slugs.has(`${key}/index`))
294
+ return `${key}/index`;
295
+ if (key === "" && slugs.has("index"))
296
+ return "index";
297
+ return null;
298
+ }
299
+ /** First nav entry that resolves to an exported page, depth-first. */
300
+ function firstNavSlug(nav, slugs) {
301
+ for (const item of nav) {
302
+ if (item.href && !/^[a-z][a-z0-9+.-]*:/i.test(item.href) && !item.href.startsWith("//")) {
303
+ const slug = hrefToSlug(item.href, slugs);
304
+ if (slug)
305
+ return slug;
142
306
  }
143
- // Match on last segment
144
- if (parts[parts.length - 1] === page.slug.split("/").pop()) {
145
- return page.slug;
307
+ if (item.children?.length) {
308
+ const inner = firstNavSlug(item.children, slugs);
309
+ if (inner)
310
+ return inner;
146
311
  }
147
312
  }
148
- // Fallback: use last path segment
149
- return parts[parts.length - 1] || null;
313
+ return null;
314
+ }
315
+ /**
316
+ * A root page that forwards to `slug`. The URL is RELATIVE (`welcome/`, or
317
+ * `a/b/` — a trailing `index` stripped, since `use_directory_urls: true`,
318
+ * which the emitted config sets, serves `a/index.md` at `a/`), so it is
319
+ * right under any `site_url` path prefix. The markdown link is the
320
+ * no-refresh fallback and lets `mkdocs build` validate the target — it is
321
+ * also the one MkDocs rewrites if someone later flips `use_directory_urls`;
322
+ * the meta URL is not. Not in `nav:`, so MkDocs still lists it in
323
+ * sitemap.xml (no per-page exclusion exists) and titles it `Site - Site`.
324
+ */
325
+ function homeRedirect(siteName, slug) {
326
+ const url = slug.replace(/(^|\/)index$/, "$1").replace(/\/?$/, "/");
327
+ // A slug is a path, not an attribute value: encode per segment for the
328
+ // meta URL, and use the `<…>` destination form so a space survives the
329
+ // markdown link.
330
+ const href = url.split("/").map(encodeURIComponent).join("/");
331
+ return [
332
+ "---",
333
+ `title: ${yamlScalar(siteName)}`,
334
+ "search:",
335
+ " exclude: true",
336
+ "---",
337
+ "",
338
+ `<meta http-equiv="refresh" content="0; url=${href}">`,
339
+ "",
340
+ `Redirecting to [${url}](<${slug}.md>)…`,
341
+ "",
342
+ ].join("\n");
150
343
  }
151
344
  // ── YAML Generation ─────────────────────────────────────────────
152
345
  function buildMkdocsYml(options, extensions, navYaml) {
153
346
  const lines = [];
154
- lines.push(`site_name: ${options.siteName}`);
347
+ lines.push(`site_name: ${yamlScalar(options.siteName)}`);
155
348
  if (options.siteUrl)
156
- lines.push(`site_url: ${options.siteUrl}`);
349
+ lines.push(`site_url: ${yamlScalar(options.siteUrl)}`);
157
350
  if (options.repoUrl)
158
- lines.push(`repo_url: ${options.repoUrl}`);
351
+ lines.push(`repo_url: ${yamlScalar(options.repoUrl)}`);
352
+ // `copyright:` is the only footer string MkDocs offers, so the attribution
353
+ // is appended to it rather than replacing it. Material renders it as HTML.
354
+ const footer = [
355
+ options.copyright,
356
+ options.poweredBy !== false
357
+ // Single-quoted attribute: a double quote here would be escaped by the
358
+ // YAML scalar and the emitted mkdocs.yml becomes unreadable.
359
+ ? "Powered by <a href='https://dogsbay.ai'>DogsBay</a>"
360
+ : undefined,
361
+ ].filter(Boolean);
362
+ if (footer.length > 0)
363
+ lines.push(`copyright: ${yamlScalar(footer.join(" &middot; "))}`);
364
+ lines.push("use_directory_urls: true");
159
365
  lines.push("");
160
366
  // Theme
161
367
  lines.push("theme:");
162
368
  lines.push(` name: ${options.theme ?? "material"}`);
163
369
  lines.push(" features:");
164
370
  lines.push(" - content.code.copy");
165
- lines.push(" - navigation.instant");
371
+ lines.push(" - content.tabs.link");
166
372
  lines.push(" - navigation.tracking");
373
+ // Material renders the WHOLE nav into every page unless told to prune.
374
+ // Measured on OpenShift 4.22 (1,800 pages, 2,100 nav entries): 1 MB per
375
+ // page, 1.9 GB total — over GitHub Pages' 1 GB site limit — with the nav
376
+ // as 95% of every file. `navigation.prune` keeps only the active path;
377
+ // Material's own docs recommend it for sites with hundreds of pages.
378
+ lines.push(" - navigation.prune");
167
379
  lines.push(" - navigation.sections");
168
- lines.push(" - toc.integrate");
380
+ lines.push(" - navigation.indexes");
381
+ lines.push(" - navigation.top");
382
+ lines.push(" - toc.follow");
169
383
  lines.push("");
170
384
  // Nav
171
- lines.push("nav:");
172
- lines.push(navYaml);
173
- lines.push("");
385
+ if (navYaml) {
386
+ lines.push("nav:");
387
+ lines.push(navYaml);
388
+ lines.push("");
389
+ }
174
390
  // Extensions
175
391
  lines.push("markdown_extensions:");
176
392
  for (const ext of extensions) {
@@ -179,7 +395,7 @@ function buildMkdocsYml(options, extensions, navYaml) {
179
395
  const extLines = ext.split("\n");
180
396
  lines.push(` - ${extLines[0]}`);
181
397
  for (let i = 1; i < extLines.length; i++) {
182
- lines.push(` ${extLines[i].trimStart()}`);
398
+ lines.push(` ${extLines[i]}`);
183
399
  }
184
400
  }
185
401
  else {
@@ -189,4 +405,3 @@ function buildMkdocsYml(options, extensions, navYaml) {
189
405
  lines.push("");
190
406
  return lines.join("\n");
191
407
  }
192
- //# sourceMappingURL=to-mkdocs-project.js.map
@@ -1,6 +1,55 @@
1
- import type { TreeNode } from "../tree.js";
2
1
  /**
3
- * Convert a TreeNode[] back to MkDocs-compatible Markdown.
2
+ * MkDocs serializer — TreeNode[] → MkDocs (Python-Markdown + PyMdown) Markdown.
3
+ *
4
+ * Built on `@dogsbay/serialize-core`: shape-tolerant leaf reads, the shared
5
+ * inline walk, and a ledger for what could not be represented. The first
6
+ * version of this file predated the core and read `node.children` only, so
7
+ * every heading and paragraph parsed from real dogsbay-md — where the text is
8
+ * on `node.inline` — serialized as `# ` with no title. The tier-3 export
9
+ * conformance suite caught it the moment the exporter was wired in.
10
+ *
11
+ * Python-Markdown differs from CommonMark in ways that matter here:
12
+ *
13
+ * - Nested block content inside a list item, admonition or tab must be
14
+ * indented by FOUR spaces. Two-space continuation (fine in CommonMark)
15
+ * detaches the block and renders it at top level.
16
+ * - There is no native tabs/steps/cards syntax; PyMdown supplies
17
+ * `=== "Tab"`, and the rest is expressed with `attr_list` / `md_in_html`.
18
+ * - Heading ids come from `attr_list` (`# Title { #id }`), and the corpus
19
+ * cross-references depend on the AUTHORED ids surviving — AsciiDoc-derived
20
+ * ids like `additional-resources_welcome-index` would not be regenerated by
21
+ * the toc extension's slugifier.
4
22
  */
5
- export declare function treeToMkdocs(nodes: TreeNode[]): string;
6
- //# sourceMappingURL=to-mkdocs.d.ts.map
23
+ import type { TreeNode, NavItem } from "@dogsbay/types";
24
+ import { ExportLedger } from "@dogsbay/serialize-core";
25
+ export interface MkdocsSerializeOptions {
26
+ /**
27
+ * Slug of the page being serialized (`welcome/oke_about`). Used to make
28
+ * site-rooted links (`/welcome/index/#anchor`) and asset refs
29
+ * (`/images/x.png`) RELATIVE to the emitted file, which is how MkDocs
30
+ * resolves and validates them. Without it links are emitted verbatim.
31
+ */
32
+ pageSlug?: string;
33
+ /**
34
+ * Every page slug in the export. A site-rooted href is rewritten to a
35
+ * relative `.md` link only when it names a page we are actually emitting;
36
+ * anything else (an asset, an external route) is left alone.
37
+ */
38
+ slugs?: ReadonlySet<string>;
39
+ /** Loss recorder. One per export run; entries are stamped with the page. */
40
+ ledger?: ExportLedger;
41
+ }
42
+ /**
43
+ * Convert a TreeNode[] to MkDocs-compatible Markdown.
44
+ */
45
+ export declare function treeToMkdocs(nodes: TreeNode[], options?: MkdocsSerializeOptions): string;
46
+ /**
47
+ * Site-rooted page links → relative `.md` links, which MkDocs resolves,
48
+ * validates, and rewrites for `use_directory_urls`. Only rewritten when the
49
+ * target is a page in this export; everything else passes through so an
50
+ * asset or external route is never turned into a dead `.md`.
51
+ */
52
+ export declare function mkdocsHref(href: string): string;
53
+ /** Normalise an href or slug to the slug form pages are keyed by. */
54
+ export declare function hrefToSlugKey(href: string): string;
55
+ export type { NavItem };