dogsbay 0.2.0-beta.37 → 0.2.0-beta.38

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.
@@ -3,6 +3,8 @@ import { join, relative, resolve, basename, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import pc from "picocolors";
5
5
  import YAML from "yaml";
6
+ import { emitAstroPages, emitAgentReadinessFiles, emitConfigDerivedFiles, emitSiteConfig, } from "@dogsbay/format-astro";
7
+ import { emitPluginRuntime } from "@dogsbay/format-astro";
6
8
  export async function importMkdocs(source, options) {
7
9
  const sourceDir = resolve(source);
8
10
  const mkdocsYml = join(sourceDir, "mkdocs.yml");
@@ -131,9 +133,11 @@ export async function importMkdocs(source, options) {
131
133
  }
132
134
  console.log(pc.green(`Copied`) + ` ${Object.keys(macrosData).length} macro data files`);
133
135
  }
134
- // 3. Extract nav structure
136
+ // 3. Extract nav structure. nav.json is written later by
137
+ // emitAstroPages (Phase 1 of plans/mkdocs-import-architecture.md
138
+ // — single canonical emitter). nav.yml is written alongside as
139
+ // the human-editable source of truth.
135
140
  const nav = convertNav(config.nav);
136
- writeFileSync(join(outputDir, "src", "data", "nav.json"), JSON.stringify(nav, null, 2));
137
141
  console.log(pc.green(`Extracted`) + ` navigation (${nav.length} top-level items)`);
138
142
  // 4. Generate package.json
139
143
  const dogsbayVersion = options.local ? "file:" : "^0.1.0";
@@ -167,13 +171,27 @@ export async function importMkdocs(source, options) {
167
171
  dependencies: {
168
172
  astro: "^6.0.0",
169
173
  tailwindcss: "^4.0.0",
170
- "@tailwindcss/vite": "^4.0.0",
174
+ // Pinned exactly to 4.2.2 — every later release in the 4.2.x
175
+ // line (4.2.3, 4.2.4) and the 4.3.x line breaks Astro 6's
176
+ // rolldown-vite with "Missing field `tsconfigPaths` on
177
+ // BindingViteResolvePluginConfig.resolveOptions" inside
178
+ // oxcResolvePlugin. The tilde range ~4.2.2 wasn't strict enough
179
+ // — npm floated it to 4.2.4. Drop when Astro 6 picks up a
180
+ // compatible rolldown build OR @tailwindcss/vite restores the
181
+ // prior shape.
182
+ "@tailwindcss/vite": "4.2.2",
171
183
  "tailwind-variants": "^0.3.0",
172
184
  "markdown-it": "^14.0.0",
173
185
  shiki: "^4.0.0",
174
186
  "@shikijs/transformers": "^4.0.0",
175
187
  ...dogsbayDeps,
176
188
  katex: "^0.16.44",
189
+ // Transitive of @dogsbay/primitives → @floating-ui/dom. npm
190
+ // doesn't hoist second-level transitives under `file:` links,
191
+ // and Rollup then can't resolve @floating-ui/core at build
192
+ // time. Listing it at the top level satisfies both linked
193
+ // and registry installs.
194
+ "@floating-ui/core": "^1.7.0",
177
195
  },
178
196
  }, null, 2) + "\n");
179
197
  if (options.local) {
@@ -315,9 +333,50 @@ export default defineConfig({
315
333
  }
316
334
  console.log(pc.green(`Added`) + ` ${needed.length} components`);
317
335
  }
318
- // 13. Generate static .astro pages (default, skip with --dynamic)
336
+ // 13. Generate static .astro pages (default, skip with --dynamic).
337
+ // Phase 1 of plans/mkdocs-import-architecture.md routes emission
338
+ // through @dogsbay/format-astro's canonical emitters so this
339
+ // command gains llms.txt, .md mirrors, sitemap, _headers, and
340
+ // middleware.ts without duplicating logic.
319
341
  if (!options.dynamic) {
320
- await generateStaticPages(outputDir, pluginOpts, mkdocstringsConfig, options, nav, config);
342
+ const pages = await collectExportPages(outputDir, pluginOpts, mkdocstringsConfig, nav);
343
+ const siteUrlRaw = config.site_url;
344
+ const siteUrl = typeof siteUrlRaw === "string" ? siteUrlRaw : undefined;
345
+ const repoUrl = typeof config.repo_url === "string" ? config.repo_url : undefined;
346
+ const astroOptions = {
347
+ siteName,
348
+ basePath: "/docs",
349
+ siteUrl,
350
+ repoUrl,
351
+ sourceDir: fullDocsDir,
352
+ llmsTxt: true,
353
+ mdMirror: true,
354
+ };
355
+ // Empty plugin runtime — emits the MarkdownContentStack passthrough
356
+ // wrapper that emitAstroPages's page templates import. Without
357
+ // this, every generated page fails to compile.
358
+ emitPluginRuntime({
359
+ outputDir,
360
+ clientModules: [],
361
+ styles: [],
362
+ clientConfigs: [],
363
+ });
364
+ // Empty switcher map — single-source MkDocs imports have no
365
+ // multi-source axes. The file must exist because emitAstroPages
366
+ // page templates `import switcherMapData from "@/data/switcherMap.json"`.
367
+ writeFileSync(join(outputDir, "src", "data", "switcherMap.json"), "[]\n");
368
+ // Refresh site.json with the full SiteConfig shape that
369
+ // emitAstroPages page templates expect (siteName, siteUrl,
370
+ // repoUrl, …). Overwrites the minimal one written at step 8.
371
+ emitSiteConfig(outputDir, siteName, astroOptions);
372
+ const { generated } = await emitAstroPages(pages, nav, outputDir, astroOptions);
373
+ emitConfigDerivedFiles(outputDir, astroOptions);
374
+ emitAgentReadinessFiles(pages, nav, outputDir, siteName, astroOptions);
375
+ // nav.yml — human-editable source of truth (per plan: "edit
376
+ // nav.yml; nav.json is regenerated on every build"). Written
377
+ // AFTER emitAstroPages so it carries the same shape as nav.json.
378
+ writeFileSync(join(outputDir, "src", "data", "nav.yml"), YAML.stringify(nav));
379
+ console.log(pc.green(`Generated`) + ` ${generated} static .astro pages`);
321
380
  }
322
381
  // Remove the catch-all route unless --dynamic or --keep-dynamic
323
382
  if (!options.dynamic && !options.keepDynamic) {
@@ -350,16 +409,27 @@ export default defineConfig({
350
409
  * Generate static .astro pages from markdown content.
351
410
  * Each .md file becomes a .astro page with real component imports.
352
411
  */
353
- async function generateStaticPages(outputDir, pluginOpts, mkdocstringsConfig, options, nav, config) {
412
+ /**
413
+ * Parse each MkDocs `.md` source through `format-mkdocs`'s parser
414
+ * (plugin pipeline: admonitions, tabs, snippets, autodoc, macros,
415
+ * variants, ...) and collect into ExportPage[] for emission via
416
+ * `@dogsbay/format-astro.emitAstroPages`.
417
+ *
418
+ * Pre-Phase 1 this function wrote `.astro` pages directly with a
419
+ * hand-templated DocsLayout wrapper, which bypassed format-astro's
420
+ * agent-readiness emission (llms.txt, .md mirrors, sitemap,
421
+ * _headers). Now the parser output is handed to format-astro's
422
+ * canonical emitter so import-mkdocs gains those files for free.
423
+ */
424
+ async function collectExportPages(outputDir, pluginOpts, mkdocstringsConfig, _nav) {
354
425
  // Dynamic imports for the parser — these packages are workspace dependencies
355
426
  // but may not have type declarations visible to the CLI's tsconfig
356
427
  const MarkdownIt = (await import(/* @vite-ignore */ "markdown-it")).default;
357
428
  const { mkdocsPlugin } = await import(/* @vite-ignore */ "@dogsbay/format-mkdocs");
358
429
  const { parseMkdocsMarkdown } = await import(/* @vite-ignore */ "@dogsbay/format-mkdocs/loader");
359
- const { treeToAstro } = await import(/* @vite-ignore */ "@dogsbay/format-mkdocs/export/to-astro");
360
430
  const docsDir = join(outputDir, "src", "content", "docs");
361
- const pagesDir = join(outputDir, "src", "pages", "docs");
362
- mkdirSync(pagesDir, { recursive: true });
431
+ // pages directory is created (and populated) by
432
+ // @dogsbay/format-astro.emitAstroPages collector only reads.
363
433
  // Build the same markdown-it instance as the catch-all route
364
434
  const md = new MarkdownIt({ html: true, linkify: true, typographer: true });
365
435
  const mdOpts = {
@@ -429,77 +499,38 @@ async function generateStaticPages(outputDir, pluginOpts, mkdocstringsConfig, op
429
499
  }
430
500
  parseOpts.autodoc = autodocOpts;
431
501
  }
432
- // Walk docs directory and generate pages
502
+ // Walk docs directory and parse each .md into a TreeNode tree.
503
+ // Collection only — emission is the caller's job.
433
504
  const mdFiles = findMarkdownFiles(docsDir);
434
- let generated = 0;
435
- // Read nav data
436
- const navJson = JSON.stringify(nav);
505
+ const pages = [];
437
506
  for (const mdPath of mdFiles) {
438
507
  const relPath = relative(docsDir, mdPath);
439
- const slug = relPath.replace(/\.md$/, "").replace(/\/index$/, "").replace(/^index$/, "");
440
- // Root index.md → index.astro (not skipped)
441
- const pageSlug = slug || "index";
508
+ // Slug = path relative to docs dir, without .md, with index files
509
+ // collapsed (mkdocs convention: foo/index.md → /foo). Root
510
+ // index.md keeps slug "index" so emitAstroPages writes it as
511
+ // src/pages/<basePath>/index.astro.
512
+ const rawSlug = relPath.replace(/\.md$/, "").replace(/\/index$/, "").replace(/^index$/, "");
513
+ const slug = rawSlug || "index";
442
514
  try {
443
515
  const source = readFileSync(mdPath, "utf-8");
444
516
  const { tree, headings } = await parseMkdocsMarkdown(source, md, {
445
517
  ...parseOpts,
446
518
  env: { filePath: relPath },
447
519
  });
448
- const result = treeToAstro(tree);
449
520
  const title = headings.find((h) => h.depth === 1)?.text || "Documentation";
450
- // Build the complete .astro page
451
- const pageLines = [
452
- "---",
453
- 'import "@/styles/global.css";',
454
- 'import "katex/dist/katex.min.css";',
455
- 'import DocsLayout from "@dogsbay/docs-layout/DocsLayout.astro";',
456
- 'import { getPagination } from "@dogsbay/docs-layout/pagination";',
457
- 'import navData from "@/data/nav.json";',
458
- 'import siteConfig from "@/data/site.json";',
459
- ...result.imports,
460
- "",
461
- `const headings = ${JSON.stringify(headings)};`,
462
- `const nav = navData;`,
463
- `const currentPath = "${slug ? `/docs/${slug}` : "/docs"}";`,
464
- `const { prev, next } = getPagination(currentPath, nav as any[]);`,
465
- `const title = ${JSON.stringify(title)};`,
466
- "---",
467
- "",
468
- `<DocsLayout`,
469
- ` siteName={siteConfig.siteName}`,
470
- ` title={title}`,
471
- ` nav={nav as any[]}`,
472
- ` headings={headings}`,
473
- ` prev={prev}`,
474
- ` next={next}`,
475
- ` repoUrl={siteConfig.repoUrl || undefined}`,
476
- `>`,
477
- ` <article class="docs-prose">`,
478
- result.body.split("\n").map((l) => ` ${l}`).join("\n"),
479
- ` </article>`,
480
- `</DocsLayout>`,
481
- ];
482
- // Add script tags for client-side elements
483
- if (result.scripts.length > 0) {
484
- pageLines.push("");
485
- for (const script of result.scripts) {
486
- pageLines.push(`<script>`);
487
- pageLines.push(script);
488
- pageLines.push(`</script>`);
489
- }
490
- }
491
- const pageContent = pageLines.join("\n") + "\n";
492
- // Write the page
493
- const pagePath = join(pagesDir, `${pageSlug}.astro`);
494
- mkdirSync(dirname(pagePath), { recursive: true });
495
- writeFileSync(pagePath, pageContent);
496
- generated++;
521
+ pages.push({
522
+ slug,
523
+ title,
524
+ tree,
525
+ headings,
526
+ frontmatter: {},
527
+ });
497
528
  }
498
529
  catch (err) {
499
- console.log(pc.yellow(` Warning: failed to generate ${relPath}: ${err.message}`));
530
+ console.log(pc.yellow(` Warning: failed to parse ${relPath}: ${err.message}`));
500
531
  }
501
532
  }
502
- console.log(pc.green(`Generated`) + ` ${generated} static .astro pages`);
533
+ return pages;
503
534
  }
504
535
  // ── File Discovery ───────────────────────────────────
505
536
  function findMarkdownFiles(dir) {
@@ -0,0 +1,536 @@
1
+ /**
2
+ * `dogsbay migrate-mkdocs` — one-way migration from a MkDocs site to a
3
+ * fully scaffolded Dogsbay site whose source-of-truth is Dogsbay-MD.
4
+ *
5
+ * Phase 3 of plans/mkdocs-import-architecture.md.
6
+ *
7
+ * Unlike `dogsbay import-mkdocs` (which keeps MkDocs as the editable
8
+ * source and re-renders to Astro on every run), `migrate-mkdocs` is
9
+ * one-shot: MkDocs goes in, a scaffolded Dogsbay project comes out,
10
+ * and the MkDocs source can be deleted. From this point on the user
11
+ * edits `./content/*.md` (canonical Dogsbay-MD) and runs `dogsbay
12
+ * site build` to produce the Astro site.
13
+ *
14
+ * Output layout:
15
+ *
16
+ * <output>/
17
+ * content/ ← NEW source-of-truth, Dogsbay-MD
18
+ * index.md
19
+ * guides/
20
+ * install.md
21
+ * public/ ← assets carried over from MkDocs docs/
22
+ * img/
23
+ * src/
24
+ * data/nav.yml ← human-editable nav (file:-shape)
25
+ * styles/{theme,global}.css
26
+ * components/ui/
27
+ * dogsbay.config.yml ← sources: [./content], agent on
28
+ * astro.config.mjs ← scaffolded by emitSiteScaffold
29
+ * package.json ← scaffolded
30
+ * tsconfig.json
31
+ * MIGRATION.md ← what survived, what didn't, re-run cmd
32
+ *
33
+ * Autodoc handling (Phase 3 default = inline):
34
+ * `::: module.Class` directives in MkDocs sources are resolved at
35
+ * migration time via @dogsbay/format-mkdocs's autodoc pipeline
36
+ * (which delegates to @dogsbay/autodoc-python). The resolved
37
+ * api-* TreeNodes are serialized into the Dogsbay-MD content; on
38
+ * subsequent `site build` they render through format-astro's
39
+ * serializer. Phase 4 will add a `:::autodoc` directive to
40
+ * Dogsbay-MD for live re-rendering at build time — until then
41
+ * migrated reference pages are a snapshot at migration time.
42
+ */
43
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, readdirSync, statSync, } from "node:fs";
44
+ import { join, relative, resolve, basename, dirname, posix } from "node:path";
45
+ import pc from "picocolors";
46
+ import YAML from "yaml";
47
+ import { emitSiteScaffold } from "@dogsbay/format-astro";
48
+ import { serializeConfig } from "../config/serialize.js";
49
+ export async function migrateMkdocs(source, options) {
50
+ const sourceDir = resolve(source);
51
+ const mkdocsYml = join(sourceDir, "mkdocs.yml");
52
+ if (!existsSync(mkdocsYml)) {
53
+ console.log(pc.red(`Error: No mkdocs.yml found in ${sourceDir}`));
54
+ console.log("Point this at a MkDocs project directory containing mkdocs.yml");
55
+ process.exit(1);
56
+ }
57
+ // Parse mkdocs.yml
58
+ const yml = readFileSync(mkdocsYml, "utf-8");
59
+ const config = YAML.parse(yml);
60
+ const siteName = config.site_name || "Documentation";
61
+ const docsDir = config.docs_dir || "docs";
62
+ const fullDocsDir = join(sourceDir, docsDir);
63
+ if (!existsSync(fullDocsDir)) {
64
+ console.log(pc.red(`Error: docs directory not found at ${fullDocsDir}`));
65
+ process.exit(1);
66
+ }
67
+ const outputDir = resolve(options.output || `${basename(sourceDir)}-dogsbay`);
68
+ // Refuse to overwrite an existing site without --force. We probe
69
+ // for dogsbay.config.yml specifically rather than the bare
70
+ // directory — many users will migrate into a directory they've
71
+ // already created (`mkdir foo && cd foo`).
72
+ const existingConfig = ["yml", "yaml", "json"]
73
+ .map((ext) => join(outputDir, `dogsbay.config.${ext}`))
74
+ .find(existsSync);
75
+ if (existingConfig && !options.force) {
76
+ console.log(pc.red(`Error: ${outputDir} already contains a Dogsbay site (${basename(existingConfig)}).`));
77
+ console.log("Pass --force to overwrite.");
78
+ process.exit(1);
79
+ }
80
+ console.log();
81
+ console.log(pc.bold(`Migrating MkDocs site to Dogsbay-MD: ${siteName}`));
82
+ console.log(`Source: ${sourceDir}`);
83
+ console.log(`Docs: ${fullDocsDir}`);
84
+ console.log(`Output: ${outputDir}`);
85
+ console.log();
86
+ // 1. Output skeleton. The canonical Dogsbay layout (see
87
+ // packages/cli/skills/platform/migration-shape/SKILL.md) puts
88
+ // `content/` and `dogsbay.config.yml` at the root; the
89
+ // generated Astro project lives under `./astro/` (created by
90
+ // emitSiteScaffold below).
91
+ const contentDir = join(outputDir, "content");
92
+ const assetsDir = join(contentDir, "_assets");
93
+ const astroDir = join(outputDir, "astro");
94
+ mkdirSync(contentDir, { recursive: true });
95
+ mkdirSync(assetsDir, { recursive: true });
96
+ mkdirSync(astroDir, { recursive: true });
97
+ // 2. Parse each MkDocs .md and serialize to Dogsbay-MD under
98
+ // ./content/. The MkDocs parser pipeline (admonitions, tabs,
99
+ // snippets, macros, autodoc, variants — see @dogsbay/format-
100
+ // mkdocs's CLAUDE.md) runs identically to import-mkdocs; only
101
+ // the final write target differs.
102
+ const { pageCount, lossy } = await collectAndWriteContent(sourceDir, fullDocsDir, outputDir, config);
103
+ console.log(pc.green(`Wrote`) + ` ${pageCount} pages to ./content/ as Dogsbay-MD`);
104
+ // 3. Convert MkDocs nav: → ./content/nav.yml in canonical
105
+ // single-key-map shape (- Label: file.md / nested children).
106
+ // See packages/cli/skills/platform/nav-file/SKILL.md.
107
+ // Do NOT emit nav.json — runtime loader prefers it and would
108
+ // silently override the human-edited yml. The loader auto-
109
+ // detects nav.yml at the content root.
110
+ const mkdocsNav = config.nav;
111
+ const navFile = convertNav(mkdocsNav);
112
+ writeFileSync(join(contentDir, "nav.yml"), YAML.stringify(navFile));
113
+ console.log(pc.green(`Extracted`) + ` navigation to content/nav.yml (${navFile.length} top-level items)`);
114
+ // 4. Copy non-markdown assets into ./content/_assets/<rel>. Per
115
+ // plans/content-assets-folder.md, assets are content-rooted
116
+ // (move-resistant) and referenced as /_assets/<rel>/foo.png.
117
+ // format-astro's copyAssets walks content/ on every site
118
+ // build and propagates them to astro/public/ automatically.
119
+ const assetCount = copyAssets(fullDocsDir, assetsDir);
120
+ if (assetCount > 0) {
121
+ console.log(pc.green(`Copied`) + ` ${assetCount} asset files to content/_assets/`);
122
+ }
123
+ // 5. Build dogsbay.config.yml. agent.{llmsTxt, mdMirror} default
124
+ // to true in config/defaults.ts — listing them explicitly
125
+ // makes the migrated config self-documenting. NO `output:`
126
+ // field — the default (./astro) is what we want; flat layout
127
+ // (`output: "."`) triggers a rebuild loop in `site dev`.
128
+ const siteUrl = config.site_url || undefined;
129
+ const repoUrl = config.repo_url || undefined;
130
+ const siteDescription = config.site_description || undefined;
131
+ const dogsbayConfig = {
132
+ schemaVersion: 1,
133
+ site: {
134
+ name: siteName,
135
+ url: siteUrl,
136
+ basePath: "/docs",
137
+ description: siteDescription,
138
+ repoUrl,
139
+ },
140
+ content: {
141
+ sources: [{ path: "./content", from: "dogsbay-md" }],
142
+ },
143
+ agent: {
144
+ llmsTxt: true,
145
+ mdMirror: true,
146
+ },
147
+ };
148
+ writeFileSync(join(outputDir, "dogsbay.config.yml"), serializeConfig(dogsbayConfig, "yaml"));
149
+ console.log(pc.green(`Wrote`) + ` dogsbay.config.yml`);
150
+ // 6. Scaffold the Astro project under ./astro/ (theme,
151
+ // package.json, astro.config.mjs, tsconfig, copied UI
152
+ // components). Same emitter `dogsbay site init` uses, just
153
+ // targeting our migrated layout.
154
+ emitSiteScaffold(astroDir, siteName, {
155
+ siteName,
156
+ siteUrl,
157
+ basePath: "/docs",
158
+ repoUrl,
159
+ llmsTxt: true,
160
+ mdMirror: true,
161
+ local: options.local,
162
+ }, true);
163
+ console.log(pc.green(`Scaffolded`) + ` Astro project at ./astro/ (theme, components, package.json)`);
164
+ // 7. MIGRATION.md — humans-only summary of the conversion.
165
+ // Captures lossy items the parser flagged so the user can
166
+ // audit them, plus the exact command to re-run if they hit
167
+ // a bug we fix later.
168
+ writeFileSync(join(outputDir, "MIGRATION.md"), buildMigrationNotes({
169
+ sourceDir,
170
+ outputDir,
171
+ siteName,
172
+ pageCount,
173
+ assetCount,
174
+ lossy,
175
+ }));
176
+ console.log(pc.green(`Wrote`) + ` MIGRATION.md`);
177
+ if (!options.quiet) {
178
+ console.log();
179
+ console.log(pc.bold(`Done! Your Dogsbay-MD site is ready.`));
180
+ console.log();
181
+ console.log("Next steps:");
182
+ console.log(` cd ${relative(process.cwd(), outputDir) || "."}`);
183
+ console.log(` npx dogsbay site dev # live preview (auto-installs)`);
184
+ console.log();
185
+ console.log("Edit ./content/*.md (canonical Dogsbay-MD) from here on.");
186
+ console.log("Review MIGRATION.md for what survived and what didn't.");
187
+ }
188
+ }
189
+ async function collectAndWriteContent(sourceDir, fullDocsDir, outputDir, mkdocsConfig) {
190
+ // Dynamic imports for the parser + serializer — same pattern
191
+ // used in import-mkdocs to avoid pulling these into the CLI's
192
+ // type-checking graph (they're workspace deps, not direct).
193
+ const MarkdownIt = (await import(/* @vite-ignore */ "markdown-it")).default;
194
+ const { mkdocsPlugin, configureFromMkdocs } = (await import(
195
+ /* @vite-ignore */ "@dogsbay/format-mkdocs"));
196
+ const { parseMkdocsMarkdown } = (await import(
197
+ /* @vite-ignore */ "@dogsbay/format-mkdocs/loader"));
198
+ const { treeToDogsbayMd } = (await import(
199
+ /* @vite-ignore */ "@dogsbay/format-dogsbay-md"));
200
+ // Match the MkDocs parser config to the source's mkdocs.yml so
201
+ // detection-driven options (snippets root, file-include, macros,
202
+ // variants) line up with what the project authored against.
203
+ const md = new MarkdownIt({ html: true, linkify: true, typographer: true });
204
+ // configureFromMkdocs tolerates both the list form and the
205
+ // mapping form of `markdown_extensions:` (FastAPI uses the
206
+ // mapping form — without that tolerance PyMdownX Blocks like
207
+ // `/// note` fall through to raw paragraphs in the output).
208
+ const detected = configureFromMkdocs(mkdocsConfig.markdown_extensions);
209
+ md.use(mkdocsPlugin, {
210
+ ...detected,
211
+ snippets: {
212
+ root: join(sourceDir, "includes"),
213
+ autoAppend: ["mkdocs.md"],
214
+ },
215
+ footnotes: true,
216
+ math: true,
217
+ // Convert `.md` links to root-relative slugs at parse time.
218
+ // baseUrl: "" gives `/foo` / `/tutorial/quickstart` form (no
219
+ // basePath prefix — site build adds /docs at render time).
220
+ // See packages/cli/skills/platform/migration-shape/SKILL.md
221
+ // → "Internal links — root-relative absolute slugs".
222
+ linkRewrite: { baseUrl: "" },
223
+ });
224
+ // Auto-detect mkdocstrings sourceRoot. Phase 3 default = inline,
225
+ // so the autodoc pipeline runs at migration time. The resolved
226
+ // api-* TreeNodes serialize through Dogsbay-MD's `renderUnknown`
227
+ // (HTML fallback) — known limitation; first-class api-*
228
+ // serialization lands with Phase 4.
229
+ const autodocSourceRoot = detectAutodocSourceRoot(sourceDir, mkdocsConfig);
230
+ const parseOpts = {
231
+ collapse: false,
232
+ youtubeEmbed: true,
233
+ diagrams: true,
234
+ };
235
+ if (autodocSourceRoot) {
236
+ parseOpts.autodoc = { sourceRoot: autodocSourceRoot };
237
+ }
238
+ const mdFiles = findMarkdownFiles(fullDocsDir);
239
+ const lossy = [];
240
+ let pageCount = 0;
241
+ for (const mdPath of mdFiles) {
242
+ const relPath = relative(fullDocsDir, mdPath);
243
+ const destPath = join(outputDir, "content", relPath);
244
+ mkdirSync(dirname(destPath), { recursive: true });
245
+ try {
246
+ const source = readFileSync(mdPath, "utf-8");
247
+ const { tree } = await parseMkdocsMarkdown(source, md, {
248
+ ...parseOpts,
249
+ env: { filePath: relPath },
250
+ });
251
+ // Rewrite image references to /_assets/<rel>/... canonical
252
+ // form. format-mkdocs's linkRewrite (configured above) already
253
+ // resolved relative paths to root-relative `/img/...` form;
254
+ // this walker just adds the `_assets/` prefix so the refs line
255
+ // up with where copyAssets deposits files. See
256
+ // packages/cli/skills/platform/migration-shape/SKILL.md
257
+ // → "Asset folder".
258
+ rewriteImageRefsToAssets(tree);
259
+ // No frontmatter title lift in Phase 3: format-mkdocs's
260
+ // TreeNode shape (heading.html / heading.inline) doesn't
261
+ // round-trip through format-dogsbay-md's heading serializer
262
+ // cleanly when the H1 is duplicated in frontmatter. Site
263
+ // build derives the title from the first H1 in the body
264
+ // anyway — no behavioural difference.
265
+ const body = treeToDogsbayMd(tree);
266
+ writeFileSync(destPath, body.endsWith("\n") ? body : body + "\n");
267
+ // Flag the autodoc HTML-fallback case so MIGRATION.md can
268
+ // call it out per-file. Detect by scanning the source for
269
+ // mkdocstrings directives — if any were resolved at parse
270
+ // time, the page contains snapshotted api-* HTML.
271
+ if (/^:::\s+\S/m.test(source)) {
272
+ lossy.push({
273
+ file: relPath,
274
+ reason: "Contains mkdocstrings ::: directives. Resolved to a snapshot at " +
275
+ "migration time; renders as raw HTML until Phase 4 adds a " +
276
+ "first-class :::autodoc directive to Dogsbay-MD.",
277
+ });
278
+ }
279
+ pageCount++;
280
+ }
281
+ catch (err) {
282
+ console.log(pc.yellow(` Warning: failed to migrate ${relPath}: ${err.message}`));
283
+ lossy.push({
284
+ file: relPath,
285
+ reason: `Parse error during migration: ${err.message}`,
286
+ });
287
+ }
288
+ }
289
+ return { pageCount, lossy };
290
+ }
291
+ function detectAutodocSourceRoot(sourceDir, mkdocsConfig) {
292
+ // mkdocs.yml -> plugins -> mkdocstrings -> handlers -> python ->
293
+ // paths is the canonical declaration site. We accept either a
294
+ // string or array and resolve relative to the mkdocs project.
295
+ const plugins = mkdocsConfig.plugins;
296
+ if (!Array.isArray(plugins))
297
+ return null;
298
+ for (const entry of plugins) {
299
+ if (entry && typeof entry === "object" && "mkdocstrings" in entry) {
300
+ const handlers = entry.mkdocstrings?.handlers;
301
+ const paths = handlers?.python?.paths;
302
+ if (typeof paths === "string")
303
+ return resolve(sourceDir, paths);
304
+ if (Array.isArray(paths) && typeof paths[0] === "string") {
305
+ return resolve(sourceDir, paths[0]);
306
+ }
307
+ }
308
+ }
309
+ // Fallback: many mkdocstrings users keep Python source at the
310
+ // project root next to mkdocs.yml.
311
+ return sourceDir;
312
+ }
313
+ function findMarkdownFiles(dir) {
314
+ const results = [];
315
+ function walk(d) {
316
+ for (const entry of readdirSync(d)) {
317
+ const full = join(d, entry);
318
+ if (statSync(full).isDirectory()) {
319
+ walk(full);
320
+ }
321
+ else if (entry.endsWith(".md")) {
322
+ results.push(full);
323
+ }
324
+ }
325
+ }
326
+ walk(dir);
327
+ return results;
328
+ }
329
+ /**
330
+ * Walk a TreeNode tree and prefix every image src with `_assets/`
331
+ * so refs match where copyAssets deposits files. format-mkdocs's
332
+ * linkRewrite has already resolved relative paths to root-relative
333
+ * form (`/img/foo.png`); this just adds the `_assets` segment.
334
+ *
335
+ * Touches:
336
+ * - inline `image` nodes' `src` field
337
+ * - inline `link` nodes recursively (anchor tags wrap images)
338
+ * - raw `html` strings on text nodes (`<img src="...">`)
339
+ * - `props.src` on block nodes (figures, etc.)
340
+ */
341
+ export function rewriteImageRefsToAssets(nodes) {
342
+ for (const node of nodes) {
343
+ if (node.inline)
344
+ rewriteImagesInInline(node.inline);
345
+ if (node.html)
346
+ node.html = rewriteImagesInHtml(node.html);
347
+ if (typeof node.props?.src === "string") {
348
+ node.props.src = rewriteAssetHref(node.props.src);
349
+ }
350
+ if (node.children)
351
+ rewriteImageRefsToAssets(node.children);
352
+ }
353
+ }
354
+ function rewriteImagesInInline(nodes) {
355
+ for (const node of nodes) {
356
+ if (node.type === "image" && typeof node.src === "string") {
357
+ node.src = rewriteAssetHref(node.src);
358
+ }
359
+ if (node.children)
360
+ rewriteImagesInInline(node.children);
361
+ }
362
+ }
363
+ function rewriteImagesInHtml(html) {
364
+ return html.replace(/<img\b([^>]*?)\bsrc="([^"]+)"([^>]*)>/g, (_, pre, src, post) => `<img${pre} src="${rewriteAssetHref(src)}"${post}>`);
365
+ }
366
+ /**
367
+ * Decide what to do with an image src:
368
+ * - external (http://, https://, data:, //) → leave alone
369
+ * - anchor (#foo) → leave alone (rare for images but safe)
370
+ * - already canonical (/_assets/...) → leave alone
371
+ * - root-relative (/img/foo.png) → prefix `/_assets`
372
+ * - relative (img/foo.png) → treat as root-relative-to-docs-root,
373
+ * prefix `/_assets/`. format-mkdocs's linkRewrite normally
374
+ * converts these to root-relative before we run, but stay
375
+ * defensive in case the parser missed one.
376
+ */
377
+ export function rewriteAssetHref(src) {
378
+ if (!src)
379
+ return src;
380
+ // External / protocol-relative / data URIs.
381
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//"))
382
+ return src;
383
+ // Anchor-only.
384
+ if (src.startsWith("#"))
385
+ return src;
386
+ // Already canonical.
387
+ if (src === "/_assets" || src.startsWith("/_assets/"))
388
+ return src;
389
+ // Normalize and prefix.
390
+ const trimmed = src.replace(/^\/+/, "").replace(/^\.\/+/, "");
391
+ return `/_assets/${posix.normalize(trimmed)}`;
392
+ }
393
+ function copyAssets(srcDocsDir, destPublicDir) {
394
+ const exts = new Set([
395
+ ".png",
396
+ ".jpg",
397
+ ".jpeg",
398
+ ".gif",
399
+ ".svg",
400
+ ".webp",
401
+ ".ico",
402
+ ".pdf",
403
+ ]);
404
+ let count = 0;
405
+ function walk(d) {
406
+ for (const entry of readdirSync(d)) {
407
+ const full = join(d, entry);
408
+ if (statSync(full).isDirectory()) {
409
+ walk(full);
410
+ }
411
+ else {
412
+ const ext = entry.substring(entry.lastIndexOf(".")).toLowerCase();
413
+ if (!exts.has(ext))
414
+ continue;
415
+ const rel = relative(srcDocsDir, full);
416
+ const dest = join(destPublicDir, rel);
417
+ mkdirSync(dirname(dest), { recursive: true });
418
+ cpSync(full, dest);
419
+ count++;
420
+ }
421
+ }
422
+ }
423
+ walk(srcDocsDir);
424
+ return count;
425
+ }
426
+ // ── MkDocs nav → single-key-map nav.yml ─────────────────────────
427
+ /**
428
+ * Convert MkDocs `nav:` to the canonical Dogsbay nav file shape —
429
+ * single-key map per entry:
430
+ *
431
+ * - Home: index.md
432
+ * - Guides:
433
+ * - Configuration: guides/configuration.md
434
+ *
435
+ * NOT the `{ label: "...", file: "..." }` shape — the runtime
436
+ * validator throws on multi-key entries. See
437
+ * packages/cli/skills/platform/nav-file/SKILL.md.
438
+ */
439
+ function convertNav(nav) {
440
+ if (!Array.isArray(nav))
441
+ return [];
442
+ const out = [];
443
+ for (const entry of nav) {
444
+ const converted = navEntryToSingleKey(entry);
445
+ if (converted)
446
+ out.push(converted);
447
+ }
448
+ return out;
449
+ }
450
+ function navEntryToSingleKey(entry) {
451
+ // Plain string: "path.md" — auto-label from filename.
452
+ if (typeof entry === "string") {
453
+ return { [labelFromPath(entry)]: entry };
454
+ }
455
+ // Object form: { Label: "path.md" } or { Label: [...] }
456
+ const keys = Object.keys(entry);
457
+ if (keys.length === 0)
458
+ return null;
459
+ const label = keys[0];
460
+ const value = entry[label];
461
+ if (typeof value === "string") {
462
+ // Internal path or external URL — single-key map stores both
463
+ // the same way; the loader distinguishes them at read time
464
+ // (anything starting with a protocol or `/` is treated as
465
+ // external — see nav-file SKILL).
466
+ return { [label]: value };
467
+ }
468
+ if (Array.isArray(value)) {
469
+ const children = [];
470
+ for (const child of value) {
471
+ const c = navEntryToSingleKey(child);
472
+ if (c)
473
+ children.push(c);
474
+ }
475
+ return { [label]: children };
476
+ }
477
+ return null;
478
+ }
479
+ function labelFromPath(path) {
480
+ const base = basename(path, ".md");
481
+ return base
482
+ .replace(/[-_]+/g, " ")
483
+ .replace(/\b\w/g, (c) => c.toUpperCase());
484
+ }
485
+ // ── MIGRATION.md ────────────────────────────────────────
486
+ function buildMigrationNotes(args) {
487
+ const { sourceDir, outputDir, siteName, pageCount, assetCount, lossy } = args;
488
+ const lines = [
489
+ `# Migration: ${siteName}`,
490
+ "",
491
+ `Generated by \`dogsbay migrate-mkdocs\`.`,
492
+ "",
493
+ `- **Source**: \`${sourceDir}\``,
494
+ `- **Output**: \`${outputDir}\``,
495
+ `- **Pages migrated**: ${pageCount}`,
496
+ `- **Assets carried over**: ${assetCount}`,
497
+ "",
498
+ "## What changed",
499
+ "",
500
+ "Your source-of-truth is now `./content/*.md` (canonical Dogsbay-MD).",
501
+ "The MkDocs source at the path above is no longer consulted by the",
502
+ "build pipeline — you can archive or delete it once you're satisfied",
503
+ "with the migration.",
504
+ "",
505
+ "Edit content with any markdown editor. Rebuild with:",
506
+ "",
507
+ "```bash",
508
+ "npm install",
509
+ "npx dogsbay site build # one-shot",
510
+ "npx dogsbay site dev # watch + preview",
511
+ "```",
512
+ "",
513
+ "## Re-running the migration",
514
+ "",
515
+ "If you find a regression and we ship a fix, re-run with `--force`:",
516
+ "",
517
+ "```bash",
518
+ `npx dogsbay migrate-mkdocs ${sourceDir} --output ${outputDir} --force`,
519
+ "```",
520
+ "",
521
+ "Note that `--force` overwrites all generated files; any hand edits",
522
+ "to `./content/*.md` since the last migration will be lost. Commit",
523
+ "your work first.",
524
+ "",
525
+ ];
526
+ if (lossy.length > 0) {
527
+ lines.push("## Known limitations", "");
528
+ lines.push("The following files have content that didn't fully round-trip", "into Dogsbay-MD. Review each one and fix manually:", "");
529
+ for (const item of lossy) {
530
+ lines.push(`- **\`${item.file}\`** — ${item.reason}`);
531
+ }
532
+ lines.push("");
533
+ }
534
+ lines.push("## Architecture", "", "Migration is one-way and runs the same parser pipeline as", "`dogsbay import-mkdocs` (Material plugins, mkdocstrings, snippets,", "macros, variants). The difference is the emission target: import", "writes `.astro` pages directly; migrate writes Dogsbay-MD source", "files. From there, `dogsbay site build` is the standard SSG", "pipeline with full agent-readiness output (llms.txt, .md mirrors,", "sitemap, Cloudflare _headers).", "", "See `plans/mkdocs-import-architecture.md` for the full design.", "");
535
+ return lines.join("\n");
536
+ }
@@ -193,6 +193,7 @@ function startContentWatcher(siteRoot, outputDir, options) {
193
193
  /(^|[\\/])astro([\\/]|$)/,
194
194
  /(^|[\\/])dist([\\/]|$)/,
195
195
  /(^|[\\/])\.dogsbay([\\/]|$)/,
196
+ /(^|[\\/])\.astro([\\/]|$)/,
196
197
  /\.swp$/,
197
198
  /\.swo$/,
198
199
  /~$/,
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { Command } from "commander";
6
6
  import { init } from "./commands/init.js";
7
7
  import { add, list } from "./commands/add.js";
8
8
  import { importMkdocs } from "./commands/import-mkdocs.js";
9
+ import { migrateMkdocs } from "./commands/migrate-mkdocs.js";
9
10
  import { preprocessVariants } from "./commands/preprocess-variants.js";
10
11
  import { lighthouse } from "./commands/lighthouse.js";
11
12
  import { convert } from "./commands/convert.js";
@@ -176,6 +177,15 @@ program
176
177
  .option("--dynamic", "Use dynamic catch-all route instead of static .astro pages")
177
178
  .option("--keep-dynamic", "Keep the dynamic [..slug] route alongside static pages (for comparison)")
178
179
  .action((source, options) => importMkdocs(source, options));
180
+ program
181
+ .command("migrate-mkdocs")
182
+ .description("One-way migrate a MkDocs site to a scaffolded Dogsbay-MD project. " +
183
+ "Source-of-truth becomes ./content/*.md; the MkDocs source can be deleted.")
184
+ .argument("<source>", "Path to MkDocs project (containing mkdocs.yml)")
185
+ .option("-o, --output <dir>", "Output directory (default: {source}-dogsbay)")
186
+ .option("--force", "Overwrite an existing Dogsbay site at the output dir")
187
+ .option("--local", "Use file: references to local monorepo packages (for development)")
188
+ .action((source, options) => migrateMkdocs(source, options));
179
189
  program
180
190
  .command("preprocess-variants")
181
191
  .description("Add variant tabs to file includes (e.g. Python 3.10+ / 3.9+ tabs)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dogsbay",
3
- "version": "0.2.0-beta.37",
3
+ "version": "0.2.0-beta.38",
4
4
  "description": "CLI for Dogsbay — scaffold, build, and serve documentation sites with markdown / MkDocs / Obsidian / OpenAPI sources",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,14 +32,14 @@
32
32
  "picocolors": "^1.1.0",
33
33
  "prompts": "^2.4.2",
34
34
  "yaml": "^2.8.3",
35
- "@dogsbay/format-mkdocs": "0.2.0-beta.37",
36
- "@dogsbay/format-astro": "0.2.0-beta.37",
37
- "@dogsbay/format-mdx": "0.2.0-beta.37",
38
- "@dogsbay/format-starlight": "0.2.0-beta.37",
39
- "@dogsbay/format-obsidian": "0.2.0-beta.37",
40
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.37",
41
- "@dogsbay/format-openapi": "0.2.0-beta.37",
42
- "@dogsbay/types": "0.2.0-beta.37"
35
+ "@dogsbay/format-mkdocs": "0.2.0-beta.38",
36
+ "@dogsbay/format-obsidian": "0.2.0-beta.38",
37
+ "@dogsbay/format-mdx": "0.2.0-beta.38",
38
+ "@dogsbay/format-astro": "0.2.0-beta.38",
39
+ "@dogsbay/format-starlight": "0.2.0-beta.38",
40
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.38",
41
+ "@dogsbay/format-openapi": "0.2.0-beta.38",
42
+ "@dogsbay/types": "0.2.0-beta.38"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^22.0.0",
@@ -0,0 +1,280 @@
1
+ ---
2
+ name: dogsbay:migration-shape
3
+ description: Canonical output shape for any source-format → Dogsbay one-way migration command (migrate-mkdocs, future migrate-obsidian, migrate-jekyll, migrate-hugo, migrate-notion, etc.). Use when implementing a migration target, reviewing migration output, or diagnosing build issues in migrated sites. Captures: project layout, asset folder convention, link normalization, nav file shape, and frontmatter conventions.
4
+ ---
5
+
6
+ # Canonical migration output shape
7
+
8
+ When a migration command produces a Dogsbay site from another source
9
+ format, the output **must** match the shape `dogsbay site init`
10
+ produces. The platform's emitters, watchers, audit rules, plugin
11
+ runtime, and component renderers all assume that shape. Drift causes
12
+ subtle bugs (rebuild loops, broken images on subpath deploys, link
13
+ 404s after page moves, malformed nav warnings).
14
+
15
+ This skill is the checklist. New migration targets read it once and
16
+ copy the shape; existing migrations read it before changing emit
17
+ logic.
18
+
19
+ ## 1. Project layout (`output: ./astro`)
20
+
21
+ ```
22
+ <output>/
23
+ ├── dogsbay.config.yml ← top-level config; references ./content
24
+ ├── content/ ← source-of-truth Dogsbay-MD
25
+ │ ├── index.md
26
+ │ ├── nav.yml ← single-key-map nav file
27
+ │ ├── _assets/ ← all assets, content-rooted
28
+ │ │ ├── diagrams/
29
+ │ │ └── screenshots/
30
+ │ ├── guides/
31
+ │ │ └── install.md
32
+ │ └── ...
33
+ ├── astro/ ← generated Astro project (Dogsbay owns)
34
+ │ ├── package.json
35
+ │ ├── astro.config.mjs
36
+ │ ├── tsconfig.json
37
+ │ ├── src/...
38
+ │ ├── public/...
39
+ │ └── dist/ ← build output
40
+ └── MIGRATION.md ← (migrations only) lossy items + re-run cmd
41
+ ```
42
+
43
+ **Required:**
44
+ - `dogsbay.config.yml` at root, references `sources: [{ path: ./content, from: dogsbay-md }]`.
45
+ - `content/` at root.
46
+ - `astro/` for the generated project — DO NOT use `output: "."`.
47
+ - Top-level files except the config + MIGRATION.md belong inside `astro/`.
48
+
49
+ **Why `output: "./astro"` is non-negotiable:**
50
+
51
+ When `output: "."`, siteRoot and outputDir are the same path. The dev
52
+ watcher recursively watches siteRoot; every `site build` writes
53
+ generated files (src/pages, src/data/*.json, src/middleware.ts,
54
+ public/llms.txt, public/<basePath>/sitemap-*.xml,
55
+ astro.config.{dogsbay,plugins}.mjs) back into siteRoot; the watcher
56
+ sees them, schedules the next build → infinite loop. The platform's
57
+ chokidar `ignored` patterns work cleanly only when `astro/` is a
58
+ sibling directory.
59
+
60
+ This was learned the hard way during Phase 3 of
61
+ `plans/mkdocs-import-architecture.md` — migrate-mkdocs initially
62
+ emitted `output: "."` and `dogsbay site dev` rebuild-looped on the
63
+ result. The fix was to revert to the standard `output: "./astro"`.
64
+
65
+ ## 2. Asset folder (`content/_assets/`)
66
+
67
+ All images, diagrams, screenshots, icons, PDFs, and downloadable
68
+ assets the docs reference go under `content/_assets/`. Authors
69
+ reference them with a leading slash:
70
+
71
+ ```markdown
72
+ ![Architecture diagram](/_assets/diagrams/architecture.png)
73
+
74
+ The product onboarding wizard:
75
+
76
+ ![Onboarding step 1](/_assets/screenshots/onboarding-step-1.png)
77
+ ```
78
+
79
+ **Required:**
80
+ - Migrations copy source assets under `<output>/content/_assets/<rel>/`.
81
+ - Rewrite every image reference to `/_assets/<rel>/...` form during
82
+ the TreeNode walk. Both `<image>` inline nodes AND `<img src="...">`
83
+ in raw HTML.
84
+ - Preserve external URLs (`https://...`, `//`, `data:`) unchanged.
85
+ - Preserve anchors (`#foo`) unchanged.
86
+
87
+ **Why `_assets/` with leading slash:**
88
+
89
+ Per `plans/content-assets-folder.md`: "The platform already chose
90
+ **identity over location** for xrefs. Image references should follow
91
+ the same logic: a stable identifier (path within the asset tree)
92
+ decoupled from where the referencing markdown lives."
93
+
94
+ - Move-resistant: a page can be reordered in nav or moved within
95
+ `content/` without breaking its image references.
96
+ - Underscore prefix is the unambiguous "reserved, not a content slug"
97
+ convention (Sphinx, Jekyll, MkDocs all use underscore-prefixed
98
+ reserved dirs).
99
+ - `format-astro`'s build-time `copyAssets` walks `content/` and copies
100
+ recognized extensions to `astro/public/<rel>/` automatically. The
101
+ migration command only has to deposit assets in `_assets/`; site
102
+ build wires the rest.
103
+
104
+ **Common source-format mappings:**
105
+
106
+ | Source | Source convention | Migration output |
107
+ |---|---|---|
108
+ | MkDocs | `docs/img/foo.png` | `content/_assets/img/foo.png` |
109
+ | Obsidian | `Attachments/foo.png` | `content/_assets/attachments/foo.png` |
110
+ | Jekyll | `assets/img/foo.png` | `content/_assets/img/foo.png` |
111
+ | Hugo | `static/foo.png` | `content/_assets/foo.png` |
112
+ | Notion | `<page-id>/foo.png` | `content/_assets/<page-slug>/foo.png` |
113
+
114
+ ## 3. Internal links — root-relative absolute slugs
115
+
116
+ In `content/*.md`, internal links are **root-relative absolute slugs**
117
+ (no extension, no basePath):
118
+
119
+ ```markdown
120
+ See [the install guide](/guides/install) for details.
121
+
122
+ Cross-reference into [the API reference](/api/endpoints).
123
+
124
+ Same-page anchor: [skip to setup](#setup).
125
+
126
+ External: [GitHub](https://github.com/example/repo).
127
+ ```
128
+
129
+ **Required rewrites at migration time:**
130
+
131
+ | Source form | Migration output |
132
+ |---|---|
133
+ | `[foo](./bar.md)` | `[foo](/bar)` |
134
+ | `[foo](../tutorial/quickstart.md)` | `[foo](/tutorial/quickstart)` |
135
+ | `[foo](section/page.md)` | `[foo](/section/page)` (resolved against page's dir) |
136
+ | `[foo](#anchor)` | unchanged |
137
+ | `[foo](https://...)` | unchanged |
138
+ | `[foo](mailto:...)` | unchanged |
139
+
140
+ **Why root-relative absolute:**
141
+
142
+ - `format-astro.rewriteTreeHrefs` adds `combined` (urlBase + basePath)
143
+ at build time. Source links must NOT include the basePath — that
144
+ layer is the platform's job.
145
+ - Top-down absolute is move-resistant: rename a page, only the file
146
+ rename + nav entry need updating, not every other markdown file
147
+ that references it.
148
+ - format-mkdocs has a `linkRewritePlugin` (`rules/link-rewrite.ts`)
149
+ that does this conversion at parse time. Call it with
150
+ `linkRewrite: { baseUrl: "" }` from migration commands — `""` gives
151
+ `/foo` form, `/docs` gives `/docs/foo` (wrong for migrate; site
152
+ build adds /docs itself).
153
+
154
+ ## 4. Nav file — single-key map shape
155
+
156
+ `content/nav.yml` is the canonical nav source. **Single-key map** per
157
+ entry, NOT `{label, file, children}` objects.
158
+
159
+ ```yaml
160
+ # ✅ CORRECT
161
+ - Home: index.md
162
+ - Getting started: getting-started.md
163
+ - Guides:
164
+ - Configuration: guides/configuration.md
165
+ - Deployment: guides/deployment.md
166
+ - Source: https://github.com/example/repo
167
+ ```
168
+
169
+ ```yaml
170
+ # ❌ WRONG — emits malformed warning, falls back to directory scan
171
+ - label: Home
172
+ file: index.md
173
+ - label: Guides
174
+ children:
175
+ - label: Configuration
176
+ file: guides/configuration.md
177
+ ```
178
+
179
+ The validator throws `Nav entry must have exactly one key at [N]`.
180
+
181
+ **Migration logic:**
182
+
183
+ ```
184
+ source nav entry → single-key-map entry
185
+ ─────────────────────────────────────────────────
186
+ "path.md" → "<auto-label-from-filename>: path.md"
187
+ { Label: "path.md" } → "Label: path.md"
188
+ { Label: [ ... ] } → "Label:\n - <recursive>"
189
+ { Label: "https://..." } → "Label: https://..."
190
+ { Label: "/abs-path" } → "Label: https://..." (external, with leading slash preserved)
191
+ ```
192
+
193
+ **Do NOT emit `nav.json` alongside `nav.yml`.** The runtime loader
194
+ prefers `nav.json` if present (`nav.json` > `nav.yml` > `nav.yaml`).
195
+ Emitting both means user edits to `nav.yml` are silently ignored.
196
+ Migrations emit `nav.yml` ONLY.
197
+
198
+ (Phase 1 of `plans/mkdocs-import-architecture.md` — the
199
+ `import-mkdocs` path — does emit both because page templates `import
200
+ nav from "@/data/nav.json"` directly. That's a separate caller pattern.
201
+ Migrations don't go through that template path; they hand off to
202
+ `dogsbay site build` which reads nav.yml.)
203
+
204
+ ## 5. Frontmatter — DO NOT lift H1 to title
205
+
206
+ Source pages often have an H1 as the first content line. **Do not
207
+ lift it to a `title:` frontmatter field during migration.** Reasons:
208
+
209
+ 1. format-mkdocs's heading TreeNode carries the title in `props.text`
210
+ (cross-shape divergence from canonical Dogsbay-MD's `heading.inline`
211
+ form). format-dogsbay-md's serializer reads `inline → html →
212
+ props.text` in that order. If you lift the title to frontmatter
213
+ AND keep the H1 in the body, the body's H1 may serialize as
214
+ `# {text="Title"}` — empty heading with text as an attribute.
215
+ 2. Site build derives the page title from the first H1 anyway. There's
216
+ no behavioural benefit to duplicating it in frontmatter.
217
+ 3. Duplicating creates a maintenance burden: the user edits the H1,
218
+ forgets to update frontmatter, browser tab + sidebar disagree.
219
+
220
+ Let the H1 stay in the body. Site build does the right thing.
221
+
222
+ ## 6. MIGRATION.md — audit trail
223
+
224
+ Every migration writes a top-level `MIGRATION.md` documenting:
225
+
226
+ - The source path (so re-runs reproduce).
227
+ - The exact re-run command with `--force`.
228
+ - A list of files with lossy content (e.g. `mkdocstrings` snapshots,
229
+ source-format plugins not yet supported, Jinja templates dropped).
230
+ - Architectural notes (where you can go for more info — link to the
231
+ plan file).
232
+
233
+ This is the audit trail for the user to manually patch what didn't
234
+ round-trip. It's not optional.
235
+
236
+ ## 7. Testing checklist
237
+
238
+ Every migration command MUST have a fixture test asserting all of:
239
+
240
+ | Assertion | Rationale |
241
+ |---|---|
242
+ | `<output>/content/*.md` exists for every source page | Content emission |
243
+ | `<output>/content/_assets/<rel>/` carries every source asset | Asset collection |
244
+ | `<output>/content/<page>.md` references images as `/_assets/...` | Asset rewrite |
245
+ | Internal `.md` links rewritten to root-relative slugs | Link normalization |
246
+ | `<output>/content/nav.yml` uses single-key-map shape | Nav contract |
247
+ | `<output>/content/nav.json` does NOT exist | Avoid loader precedence trap |
248
+ | `<output>/dogsbay.config.yml` has `sources: [{ path: ./content, from: dogsbay-md }]` | Config wires content to source |
249
+ | `<output>/dogsbay.config.yml` does NOT set `output:` | Use default `./astro` |
250
+ | `<output>/astro/package.json` exists | Scaffold under astro/ |
251
+ | `<output>/astro/astro.config.mjs` exists | Scaffold under astro/ |
252
+ | `<output>/astro/src/styles/theme.css` exists | Scaffold under astro/ |
253
+ | `<output>/MIGRATION.md` exists | Audit trail |
254
+
255
+ End-to-end smoke (manual, against real corpus):
256
+
257
+ ```bash
258
+ dogsbay migrate-<source> <source-path> --output ./migrated --local
259
+ cd ./migrated
260
+ npm install # in ./astro? or root? See output below
261
+ npx dogsbay site build # produces astro/dist/
262
+ npx dogsbay site dev # MUST NOT loop — proves layout is correct
263
+ ```
264
+
265
+ ## 8. What this skill explicitly does NOT cover
266
+
267
+ These are intentional gaps for the migration phase:
268
+
269
+ - **`:::autodoc` directive emission** — Phase 4 of `plans/mkdocs-import-architecture.md`. Until then, migrations resolve autodoc at migration time (snapshot) and flag affected pages in MIGRATION.md.
270
+ - **Identity-based xrefs** (`xref:component:module:page[]`) — see `plans/identity-based-xrefs.md`. Not scheduled; migrations use markdown link syntax.
271
+ - **Plugin coverage** — what's preserved across the migration is bounded by what the source format's parser package (`format-mkdocs`, future `format-obsidian`, etc.) supports. Missing extensions degrade to raw markdown text and get flagged in MIGRATION.md.
272
+
273
+ ## See also
274
+
275
+ - `plans/mkdocs-import-architecture.md` — the multi-phase migration plan that originated this convention
276
+ - `plans/content-assets-folder.md` — full design rationale for `_assets/`
277
+ - `plans/identity-based-xrefs.md` — strategic direction for move-resistant addressing
278
+ - `packages/cli/skills/platform/nav-file/SKILL.md` — nav file shape (referenced from this skill)
279
+ - `docs-dev/migrate-mkdocs.md` — implementation notes for the first migration command
280
+ - `research/formats/dogsbay-markdown-spec.md` — the Dogsbay-MD language spec