dogsbay 0.2.0-beta.39 → 0.2.0-beta.40

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.
@@ -99,7 +99,7 @@ export async function migrateMkdocs(source, options) {
99
99
  // snippets, macros, autodoc, variants — see @dogsbay/format-
100
100
  // mkdocs's CLAUDE.md) runs identically to import-mkdocs; only
101
101
  // the final write target differs.
102
- const { pageCount, lossy } = await collectAndWriteContent(sourceDir, fullDocsDir, outputDir, config);
102
+ const { pageCount, lossy } = await collectAndWriteContent(sourceDir, fullDocsDir, outputDir, config, { inlineAutodoc: options.inlineAutodoc ?? false });
103
103
  console.log(pc.green(`Wrote`) + ` ${pageCount} pages to ./content/ as Dogsbay-MD`);
104
104
  // 3. Convert MkDocs nav: → ./content/nav.yml in canonical
105
105
  // single-key-map shape (- Label: file.md / nested children).
@@ -120,6 +120,12 @@ export async function migrateMkdocs(source, options) {
120
120
  if (assetCount > 0) {
121
121
  console.log(pc.green(`Copied`) + ` ${assetCount} asset files to content/_assets/`);
122
122
  }
123
+ // 4b. Collect extra_css from mkdocs.yml. Paths are relative to
124
+ // docs_dir (MkDocs convention). emitSiteScaffold below
125
+ // copies the files to astro/src/styles/custom/ AND appends
126
+ // `@import "./custom/<file>";` to global.css so they load on
127
+ // every page.
128
+ const extraCss = extractExtraCssRel(config, fullDocsDir);
123
129
  // 5. Build dogsbay.config.yml. agent.{llmsTxt, mdMirror} default
124
130
  // to true in config/defaults.ts — listing them explicitly
125
131
  // makes the migrated config self-documenting. NO `output:`
@@ -159,7 +165,16 @@ export async function migrateMkdocs(source, options) {
159
165
  llmsTxt: true,
160
166
  mdMirror: true,
161
167
  local: options.local,
168
+ // Extra CSS from the source mkdocs.yml. emitSiteScaffold
169
+ // copies the files into astro/src/styles/custom/ and
170
+ // appends imports to global.css so they apply site-wide.
171
+ extraCss,
172
+ sourceDir: fullDocsDir,
162
173
  }, true);
174
+ if (extraCss.length > 0) {
175
+ console.log(pc.green(`Copied`) +
176
+ ` ${extraCss.length} extra_css file(s) to astro/src/styles/custom/`);
177
+ }
163
178
  console.log(pc.green(`Scaffolded`) + ` Astro project at ./astro/ (theme, components, package.json)`);
164
179
  // 7. MIGRATION.md — humans-only summary of the conversion.
165
180
  // Captures lossy items the parser flagged so the user can
@@ -186,7 +201,7 @@ export async function migrateMkdocs(source, options) {
186
201
  console.log("Review MIGRATION.md for what survived and what didn't.");
187
202
  }
188
203
  }
189
- async function collectAndWriteContent(sourceDir, fullDocsDir, outputDir, mkdocsConfig) {
204
+ async function collectAndWriteContent(sourceDir, fullDocsDir, outputDir, mkdocsConfig, opts) {
190
205
  // Dynamic imports for the parser + serializer — same pattern
191
206
  // used in import-mkdocs to avoid pulling these into the CLI's
192
207
  // type-checking graph (they're workspace deps, not direct).
@@ -206,6 +221,11 @@ async function collectAndWriteContent(sourceDir, fullDocsDir, outputDir, mkdocsC
206
221
  // mapping form — without that tolerance PyMdownX Blocks like
207
222
  // `/// note` fall through to raw paragraphs in the output).
208
223
  const detected = configureFromMkdocs(mkdocsConfig.markdown_extensions);
224
+ // Extract macros include_yaml + autodoc paths from mkdocs.yml so
225
+ // the parser expands them at migration time. Native Dogsbay-MD
226
+ // templating + includes are deferred — see
227
+ // plans/dogsbay-md-templating.md.
228
+ const macrosData = extractMacrosData(mkdocsConfig, sourceDir);
209
229
  md.use(mkdocsPlugin, {
210
230
  ...detected,
211
231
  snippets: {
@@ -220,21 +240,52 @@ async function collectAndWriteContent(sourceDir, fullDocsDir, outputDir, mkdocsC
220
240
  // See packages/cli/skills/platform/migration-shape/SKILL.md
221
241
  // → "Internal links — root-relative absolute slugs".
222
242
  linkRewrite: { baseUrl: "" },
243
+ // fileInclude: `{* ../../docs_src/foo.py *}` in MkDocs sources
244
+ // resolves relative to the mkdocs project root (where
245
+ // mkdocs.yml lives). Setting docsDir = sourceDir makes
246
+ // resolve() walk paths from the project root.
247
+ fileInclude: { root: sourceDir, docsDir: sourceDir },
248
+ // variants: auto-generate `//// tab` blocks from sibling files
249
+ // (foo.md + foo_py310.md → tabs with "Python 3.10+" / current).
250
+ // Pointed at the actual docs_dir so the sibling walk reads the
251
+ // right tree.
252
+ variants: { docsDir: fullDocsDir },
253
+ // templates (mkdocs-macros plugin): Jinja2/nunjucks expressions
254
+ // like `{{ contributors }}` / `{% for %}`. The parser reads
255
+ // the YAMLs at parse time, expands the templates, and the
256
+ // output is plain Dogsbay-MD prose.
257
+ ...(macrosData ? { templates: { dataFiles: macrosData } } : {}),
223
258
  });
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);
259
+ // Auto-detect mkdocstrings sourceRoot. Phase 4 default = preserve:
260
+ // `:::` directives stay as paragraph text after parse, then a
261
+ // post-walk converts them to Dogsbay-MD `autodoc-ref` TreeNodes
262
+ // which serialize as `:::autodoc{ref="..."}`. site-build resolves
263
+ // them on every build via @dogsbay/autodoc-python — Python signature
264
+ // changes propagate without a re-migration. The `--inline-autodoc`
265
+ // flag opts into the older snapshot behaviour (autodoc resolved at
266
+ // migration time, written as `:::api-*` directives).
267
+ const autodocSourceRootAbs = detectAutodocSourceRoot(sourceDir, mkdocsConfig);
268
+ // For the directive form we store the sourceRoot RELATIVE to the
269
+ // migrated site's config dir (= outputDir). That way the directives
270
+ // survive moving the migrated project around the filesystem; site
271
+ // build resolves them against the config location.
272
+ const autodocSourceRootRel = autodocSourceRootAbs
273
+ ? toRelativeFromConfig(autodocSourceRootAbs, outputDir)
274
+ : null;
230
275
  const parseOpts = {
231
276
  collapse: false,
232
277
  youtubeEmbed: true,
233
278
  diagrams: true,
234
279
  };
235
- if (autodocSourceRoot) {
236
- parseOpts.autodoc = { sourceRoot: autodocSourceRoot };
280
+ if (opts.inlineAutodoc && autodocSourceRootAbs) {
281
+ // Snapshot mode: resolve at migration time. The resolved api-*
282
+ // TreeNodes serialize to Dogsbay-MD `:::api-*` directives via
283
+ // format-dogsbay-md's Phase 4a cases.
284
+ parseOpts.autodoc = { sourceRoot: autodocSourceRootAbs };
237
285
  }
286
+ // Preserve mode (default): no `autodoc` in parseOpts. The `:::`
287
+ // paragraphs stay as raw text; we walk them in post-process and
288
+ // convert to autodoc-ref TreeNodes.
238
289
  const mdFiles = findMarkdownFiles(fullDocsDir);
239
290
  const lossy = [];
240
291
  let pageCount = 0;
@@ -248,6 +299,14 @@ async function collectAndWriteContent(sourceDir, fullDocsDir, outputDir, mkdocsC
248
299
  ...parseOpts,
249
300
  env: { filePath: relPath },
250
301
  });
302
+ // Preserve mode (default): convert any mkdocstrings `:::` text
303
+ // paragraphs in the parsed tree to Dogsbay-MD `autodoc-ref`
304
+ // TreeNodes. The serializer emits these as `:::autodoc{...}`
305
+ // directives; site-build resolves them on every build. Skipped
306
+ // when --inline-autodoc resolved them at parse time already.
307
+ if (!opts.inlineAutodoc && autodocSourceRootRel) {
308
+ rewriteMkdocstringsDirectivesToAutodocRefs(tree, autodocSourceRootRel);
309
+ }
251
310
  // Rewrite image references to /_assets/<rel>/... canonical
252
311
  // form. format-mkdocs's linkRewrite (configured above) already
253
312
  // resolved relative paths to root-relative `/img/...` form;
@@ -264,16 +323,21 @@ async function collectAndWriteContent(sourceDir, fullDocsDir, outputDir, mkdocsC
264
323
  // anyway — no behavioural difference.
265
324
  const body = treeToDogsbayMd(tree);
266
325
  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.
326
+ // Note pages with mkdocstrings directives in MIGRATION.md.
327
+ // Default mode emits :::autodoc directives that site-build
328
+ // resolves on every build (live). --inline-autodoc snapshots
329
+ // at migration time (frozen).
271
330
  if (/^:::\s+\S/m.test(source)) {
272
331
  lossy.push({
273
332
  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.",
333
+ reason: opts.inlineAutodoc
334
+ ? "Contains mkdocstrings ::: directives. --inline-autodoc " +
335
+ "resolved them to a snapshot at migration time — Python " +
336
+ "signature changes will NOT propagate until you re-migrate."
337
+ : "Contains mkdocstrings ::: directives. Converted to " +
338
+ ":::autodoc{ref=\"...\"} directives. site-build resolves " +
339
+ "them on every build via @dogsbay/autodoc-python — Python " +
340
+ "signature changes propagate automatically.",
277
341
  });
278
342
  }
279
343
  pageCount++;
@@ -288,27 +352,162 @@ async function collectAndWriteContent(sourceDir, fullDocsDir, outputDir, mkdocsC
288
352
  }
289
353
  return { pageCount, lossy };
290
354
  }
355
+ /**
356
+ * Auto-detect the Python source root for mkdocstrings autodoc.
357
+ *
358
+ * mkdocs.yml's `plugins:` can be either a list (canonical) or a
359
+ * mapping (FastAPI-style) — `mkdocstrings` is the key in both. We
360
+ * extract its config and walk:
361
+ *
362
+ * 1. mkdocstrings.handlers.python.paths (explicit declaration)
363
+ * 2. mkdocstrings.paths (alternate location seen in some sites)
364
+ * 3. Walk up from sourceDir looking for a directory that contains
365
+ * ONE OF: pyproject.toml, setup.py, or a Python package
366
+ * matching the autodoc identifiers (e.g. `fastapi/__init__.py`).
367
+ * Tops out 4 levels up; if nothing found, returns null and
368
+ * the migration warns + skips autodoc.
369
+ *
370
+ * Returns null when mkdocstrings isn't declared at all.
371
+ */
291
372
  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.
373
+ const mkdocstrings = extractMkdocstringsConfig(mkdocsConfig);
374
+ if (!mkdocstrings)
375
+ return null;
376
+ // Explicit `paths:` declaration.
377
+ const explicit = mkdocstrings.handlers?.python?.paths ?? mkdocstrings.paths;
378
+ if (typeof explicit === "string")
379
+ return resolve(sourceDir, explicit);
380
+ if (Array.isArray(explicit) && typeof explicit[0] === "string") {
381
+ return resolve(sourceDir, explicit[0]);
382
+ }
383
+ // Walk up looking for a Python project marker. FastAPI's layout
384
+ // puts mkdocs.yml at docs/en/mkdocs.yml and the package at
385
+ // ../../fastapi/, so we check up to 4 levels.
386
+ let dir = sourceDir;
387
+ for (let i = 0; i < 4; i++) {
388
+ if (existsSync(join(dir, "pyproject.toml")) ||
389
+ existsSync(join(dir, "setup.py"))) {
390
+ return dir;
391
+ }
392
+ const parent = dirname(dir);
393
+ if (parent === dir)
394
+ break;
395
+ dir = parent;
396
+ }
397
+ return null;
398
+ }
399
+ /**
400
+ * Pull the `mkdocstrings:` block out of mkdocs.yml's plugins field,
401
+ * which can be either a list-of-entries or a mapping. Returns
402
+ * null if mkdocstrings isn't declared.
403
+ */
404
+ function extractMkdocstringsConfig(mkdocsConfig) {
295
405
  const plugins = mkdocsConfig.plugins;
296
- if (!Array.isArray(plugins))
406
+ if (Array.isArray(plugins)) {
407
+ for (const entry of plugins) {
408
+ if (typeof entry === "string" && entry === "mkdocstrings")
409
+ return {};
410
+ if (entry && typeof entry === "object" && "mkdocstrings" in entry) {
411
+ const value = entry.mkdocstrings;
412
+ if (value && typeof value === "object") {
413
+ return value;
414
+ }
415
+ return {};
416
+ }
417
+ }
418
+ return null;
419
+ }
420
+ if (plugins && typeof plugins === "object") {
421
+ // Mapping form (FastAPI uses this).
422
+ const map = plugins;
423
+ if (!("mkdocstrings" in map))
424
+ return null;
425
+ const value = map.mkdocstrings;
426
+ if (value && typeof value === "object")
427
+ return value;
428
+ return {};
429
+ }
430
+ return null;
431
+ }
432
+ /**
433
+ * Extract macros plugin data file mappings from mkdocs.yml.
434
+ * Returns { varName: absolutePath } for each `include_yaml` entry,
435
+ * or null if the macros plugin isn't configured.
436
+ *
437
+ * Mirrors import-mkdocs's extractMacrosData. The `plugins:` block
438
+ * can be either a list (canonical) or a mapping (FastAPI-style);
439
+ * accept both. The include_yaml entries are themselves a list of
440
+ * single-key maps:
441
+ *
442
+ * plugins:
443
+ * macros:
444
+ * include_yaml:
445
+ * - github_sponsors: ../en/data/github_sponsors.yml
446
+ * - people: ../en/data/people.yml
447
+ */
448
+ function extractMacrosData(mkdocsConfig, sourceDir) {
449
+ const plugins = mkdocsConfig.plugins;
450
+ if (!plugins)
451
+ return null;
452
+ let macrosConfig = null;
453
+ if (Array.isArray(plugins)) {
454
+ for (const p of plugins) {
455
+ if (typeof p === "object" && p !== null && "macros" in p) {
456
+ macrosConfig = p.macros;
457
+ break;
458
+ }
459
+ if (p === "macros") {
460
+ macrosConfig = {};
461
+ break;
462
+ }
463
+ }
464
+ }
465
+ else if (typeof plugins === "object") {
466
+ if ("macros" in plugins) {
467
+ macrosConfig =
468
+ plugins.macros || {};
469
+ }
470
+ }
471
+ if (!macrosConfig)
472
+ return null;
473
+ const includeYaml = macrosConfig.include_yaml;
474
+ if (!includeYaml || !Array.isArray(includeYaml))
297
475
  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]);
476
+ const result = {};
477
+ for (const entry of includeYaml) {
478
+ if (typeof entry === "string") {
479
+ const name = entry.replace(/\.[^.]+$/, "").split("/").pop() || entry;
480
+ result[name] = resolve(sourceDir, entry);
481
+ }
482
+ else if (typeof entry === "object" && entry !== null) {
483
+ const [name, path] = Object.entries(entry)[0];
484
+ if (typeof path === "string") {
485
+ result[name] = resolve(sourceDir, path);
306
486
  }
307
487
  }
308
488
  }
309
- // Fallback: many mkdocstrings users keep Python source at the
310
- // project root next to mkdocs.yml.
311
- return sourceDir;
489
+ return Object.keys(result).length > 0 ? result : null;
490
+ }
491
+ /**
492
+ * Read mkdocs.yml's `extra_css:` list — returns paths verbatim
493
+ * (relative to docs_dir, the MkDocs convention) for the entries
494
+ * whose files exist on disk. The list is passed through to
495
+ * emitSiteScaffold's `extraCss` + `sourceDir` options, which
496
+ * copies the files into astro/src/styles/custom/ and appends
497
+ * `@import` statements to the generated global.css.
498
+ */
499
+ function extractExtraCssRel(mkdocsConfig, fullDocsDir) {
500
+ const raw = mkdocsConfig.extra_css;
501
+ if (!Array.isArray(raw))
502
+ return [];
503
+ const out = [];
504
+ for (const entry of raw) {
505
+ if (typeof entry !== "string")
506
+ continue;
507
+ if (existsSync(resolve(fullDocsDir, entry)))
508
+ out.push(entry);
509
+ }
510
+ return out;
312
511
  }
313
512
  function findMarkdownFiles(dir) {
314
513
  const results = [];
@@ -390,6 +589,108 @@ export function rewriteAssetHref(src) {
390
589
  const trimmed = src.replace(/^\/+/, "").replace(/^\.\/+/, "");
391
590
  return `/_assets/${posix.normalize(trimmed)}`;
392
591
  }
592
+ // ── mkdocstrings ::: → :::autodoc{ref="..."} (preserve mode) ──
593
+ /**
594
+ * After parseMkdocsMarkdown returns (without the autodoc option),
595
+ * `::: identifier` directives appear as paragraph nodes whose first
596
+ * prose child's html starts with `:::` text. Walk the tree, find
597
+ * them, and replace each with an autodoc-ref TreeNode that
598
+ * serializes to a Dogsbay-MD `:::autodoc{ref="..."}` directive.
599
+ *
600
+ * Site-build later resolves these via @dogsbay/autodoc-python on
601
+ * every build, so the rendered API ref stays in sync with the
602
+ * Python source.
603
+ *
604
+ * The mkdocstrings YAML option block (indented after the directive)
605
+ * is parsed and merged into the autodoc-ref props.
606
+ */
607
+ function rewriteMkdocstringsDirectivesToAutodocRefs(nodes, sourceRootRel) {
608
+ for (let i = 0; i < nodes.length; i++) {
609
+ const node = nodes[i];
610
+ if (node.type === "paragraph" && node.children) {
611
+ const proseChild = node.children.find((c) => c.type === "prose" && typeof c.html === "string" && c.html);
612
+ if (proseChild?.html) {
613
+ const directive = parseMkdocstringsDirective(proseChild.html.trim());
614
+ if (directive) {
615
+ nodes[i] = {
616
+ type: "autodoc-ref",
617
+ props: {
618
+ ref: directive.ref,
619
+ sourceRoot: sourceRootRel,
620
+ ...directive.options,
621
+ },
622
+ };
623
+ continue;
624
+ }
625
+ }
626
+ }
627
+ if (node.children) {
628
+ rewriteMkdocstringsDirectivesToAutodocRefs(node.children, sourceRootRel);
629
+ }
630
+ }
631
+ }
632
+ const MKDOCSTRINGS_FIRST_LINE_RE = /^:::\s+(\S+)\s*$/;
633
+ /**
634
+ * Parse the mkdocstrings directive text (`::: identifier` +
635
+ * optional indented YAML body). Returns null if the text doesn't
636
+ * look like a directive.
637
+ *
638
+ * ::: fastapi.FastAPI
639
+ * options:
640
+ * show_source: true
641
+ * members:
642
+ * - get
643
+ * - post
644
+ *
645
+ * Becomes:
646
+ *
647
+ * { ref: "fastapi.FastAPI",
648
+ * options: { showSource: true, members: ["get", "post"] } }
649
+ *
650
+ * The handler-options snake_case → camelCase mapping mirrors the
651
+ * one in @dogsbay/format-mkdocs's loader so the autodoc engine
652
+ * receives the same keys regardless of mode (preserve vs inline).
653
+ */
654
+ function parseMkdocstringsDirective(text) {
655
+ const firstLine = text.split("\n")[0].trim();
656
+ const match = MKDOCSTRINGS_FIRST_LINE_RE.exec(firstLine);
657
+ if (!match)
658
+ return null;
659
+ const ref = match[1];
660
+ const remainder = text.split("\n").slice(1).join("\n").trim();
661
+ const options = {};
662
+ if (remainder) {
663
+ try {
664
+ const parsed = YAML.parse(remainder);
665
+ if (parsed && typeof parsed === "object" && parsed.options) {
666
+ for (const [snake, value] of Object.entries(parsed.options)) {
667
+ options[snakeToCamel(snake)] = value;
668
+ }
669
+ }
670
+ }
671
+ catch {
672
+ // Malformed YAML — drop the options, keep the directive
673
+ // pointing at the bare ref. Better than failing the whole
674
+ // page migration.
675
+ }
676
+ }
677
+ return { ref, options };
678
+ }
679
+ function snakeToCamel(s) {
680
+ return s.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
681
+ }
682
+ /**
683
+ * Compute the sourceRoot to bake into `:::autodoc{sourceRoot="..."}`
684
+ * directives. The migrated site lives at `outputDir`; we want a
685
+ * path relative to that so the directives travel with the site if
686
+ * it's moved.
687
+ */
688
+ function toRelativeFromConfig(absSourcePath, outputDir) {
689
+ const rel = relative(outputDir, absSourcePath);
690
+ // Ensure forward slashes in the emitted string regardless of
691
+ // platform — the directives are read by site-build cross-platform.
692
+ return rel.split(/[\\/]/).join("/") || ".";
693
+ }
393
694
  function copyAssets(srcDocsDir, destPublicDir) {
394
695
  const exts = new Set([
395
696
  ".png",
@@ -165,6 +165,28 @@ export async function siteBuild(cwd, options) {
165
165
  if (resolvedPlugins.some((p) => p.plugin.transformNav)) {
166
166
  nav = await runTransformNav(resolvedPlugins, baseCtx, nav);
167
167
  }
168
+ // 7c.5. Resolve :::autodoc directives — Phase 4 of
169
+ // plans/mkdocs-import-architecture.md. Walk every page tree for
170
+ // `autodoc-ref` placeholders left by format-dogsbay-md's parser,
171
+ // call @dogsbay/autodoc-python (resolveIdentifier + symbolToTreeNode),
172
+ // and splice the rendered api-* tree in place. Failures log a
173
+ // warning and leave the placeholder intact (its fallback body
174
+ // renders). Done BEFORE strict ref validation (7d) so structural
175
+ // checks see the resolved tree, not the placeholder.
176
+ const { resolveAutodocRefs } = await import("../resolve-autodoc.js");
177
+ const autodocResult = await resolveAutodocRefs(pages, {
178
+ configDir: dirname(configPath),
179
+ });
180
+ if (autodocResult.resolved > 0 || autodocResult.failed > 0) {
181
+ const parts = [];
182
+ if (autodocResult.resolved > 0) {
183
+ parts.push(`${autodocResult.resolved} resolved`);
184
+ }
185
+ if (autodocResult.failed > 0) {
186
+ parts.push(`${autodocResult.failed} failed`);
187
+ }
188
+ console.log(` Autodoc: ${parts.join(", ")}`);
189
+ }
168
190
  console.log(` Imported ${pages.length} pages from ${importer.name}` +
169
191
  (draftCount > 0 && !options.includeDrafts
170
192
  ? ` (${draftCount} draft${draftCount === 1 ? "" : "s"} excluded)`
package/dist/index.js CHANGED
@@ -184,6 +184,9 @@ program
184
184
  .argument("<source>", "Path to MkDocs project (containing mkdocs.yml)")
185
185
  .option("-o, --output <dir>", "Output directory (default: {source}-dogsbay)")
186
186
  .option("--force", "Overwrite an existing Dogsbay site at the output dir")
187
+ .option("--inline-autodoc", "Resolve mkdocstrings ::: directives at migration time (snapshot). " +
188
+ "Default is :::autodoc directives that site-build resolves on " +
189
+ "every build (live re-rendering).")
187
190
  .option("--local", "Use file: references to local monorepo packages (for development)")
188
191
  .action((source, options) => migrateMkdocs(source, options));
189
192
  program
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Build-time resolver for the `:::autodoc` directive.
3
+ *
4
+ * Phase 4 of plans/mkdocs-import-architecture.md.
5
+ *
6
+ * format-dogsbay-md parses `:::autodoc{ref="..."}` blocks into
7
+ * `autodoc-ref` TreeNode placeholders. site-build calls
8
+ * `resolveAutodocRefs` between the importer's content load and
9
+ * format-astro's emission to substitute the placeholders with
10
+ * rendered api-* TreeNodes (api-class, api-member, api-doc, etc.).
11
+ *
12
+ * Resolution flow per ref:
13
+ * 1. Read `ref` + `sourceRoot` (+ render options) from props.
14
+ * 2. Call @dogsbay/autodoc-python.resolveIdentifier → SymbolInfo.
15
+ * 3. Call symbolToTreeNode → TreeNode.
16
+ * 4. Replace the autodoc-ref node in the page tree.
17
+ *
18
+ * Failures (missing source, unresolvable identifier, parse error)
19
+ * leave the autodoc-ref node intact and log a warning. The
20
+ * directive's fallback body (if any) renders in its place.
21
+ */
22
+ import { resolve as resolvePath } from "node:path";
23
+ import MarkdownIt from "markdown-it";
24
+ import pc from "picocolors";
25
+ import { resolveIdentifier, symbolToTreeNode, } from "@dogsbay/autodoc-python";
26
+ /**
27
+ * Default render options for autodoc directives. Mirrors
28
+ * mkdocstrings' Python handler defaults — these are the same
29
+ * values format-mkdocs/src/loader.ts baked in during the legacy
30
+ * import flow, kept here so migrate-mkdocs + site-build produce
31
+ * output that matches the legacy import path by default. A
32
+ * directive's own props override any default it doesn't like
33
+ * (e.g. `showSource: false` to skip the collapsible source).
34
+ */
35
+ const AUTODOC_RENDER_DEFAULTS = {
36
+ showRootHeading: false,
37
+ showRootFullPath: true,
38
+ showIfNoDocstring: false,
39
+ showBases: true,
40
+ showSource: true,
41
+ showSignature: true,
42
+ mergeInitIntoClass: false,
43
+ separateSignature: false,
44
+ unwrapAnnotated: false,
45
+ groupByCategory: true,
46
+ inheritedMembers: false,
47
+ filters: ["!^_[^_]"],
48
+ membersOrder: "alphabetical",
49
+ headingLevel: 2,
50
+ };
51
+ /**
52
+ * Lightweight slugifier matching format-mkdocs's behaviour so the
53
+ * heading slugs we generate here line up with what hrefs against
54
+ * these symbols expect.
55
+ */
56
+ function slugify(text) {
57
+ return text
58
+ .toLowerCase()
59
+ .replace(/[^\w\s-]/g, "")
60
+ .replace(/\s+/g, "-")
61
+ .replace(/-+/g, "-")
62
+ .replace(/^-|-$/g, "");
63
+ }
64
+ /**
65
+ * Walk every page's TreeNode tree and substitute `autodoc-ref`
66
+ * placeholders with rendered api-* trees.
67
+ *
68
+ * Pages are mutated in place — caller owns the array. Returns a
69
+ * tally of resolved / failed refs for the build log.
70
+ */
71
+ export async function resolveAutodocRefs(pages, options) {
72
+ let resolved = 0;
73
+ let failed = 0;
74
+ const md = new MarkdownIt({ html: true, linkify: true });
75
+ for (const page of pages) {
76
+ await walkAndResolve(page.tree, {
77
+ configDir: options.configDir,
78
+ md,
79
+ onResolved: () => {
80
+ resolved++;
81
+ },
82
+ onFailed: (ref, reason) => {
83
+ failed++;
84
+ console.warn(pc.yellow(` Warning: autodoc ref "${ref}" failed to resolve — ${reason}. ` +
85
+ `Page: ${page.slug}`));
86
+ },
87
+ });
88
+ // After substitution, walk the resolved tree and append heading
89
+ // entries for every api-class / api-member. Without this, the
90
+ // TOC component only sees the page's prose H1 — the rendered
91
+ // API ref pages have no method index in the sidebar TOC.
92
+ //
93
+ // Mirrors format-mkdocs/src/loader.ts → extractHeadings.
94
+ if (page.tree.some(treeHasApiHeading)) {
95
+ appendApiHeadings(page);
96
+ }
97
+ }
98
+ return { resolved, failed };
99
+ }
100
+ /**
101
+ * True if the subtree carries at least one api-class / api-member —
102
+ * cheap pre-check before we walk to collect headings.
103
+ */
104
+ function treeHasApiHeading(node) {
105
+ if (node.type === "api-class" || node.type === "api-member")
106
+ return true;
107
+ if (node.children) {
108
+ for (const child of node.children) {
109
+ if (treeHasApiHeading(child))
110
+ return true;
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+ /**
116
+ * Walk the page's TreeNode tree for api-class / api-member nodes
117
+ * and append a Heading entry for each. Depth follows format-
118
+ * mkdocs's convention: api-class is depth 2 (top-level symbol),
119
+ * api-member is depth 3 (method / attribute under a class). The
120
+ * `kind` prop is preserved on the heading so the TOC component
121
+ * can render type-colored badges (class blue, method green, etc.).
122
+ *
123
+ * Slug allocation respects existing entries in page.headings to
124
+ * avoid collisions with handwritten prose headings.
125
+ */
126
+ function appendApiHeadings(page) {
127
+ const seen = new Map();
128
+ for (const h of page.headings ?? []) {
129
+ seen.set(h.slug, (seen.get(h.slug) ?? 0) + 1);
130
+ }
131
+ const headings = [];
132
+ function walk(nodes) {
133
+ for (const node of nodes) {
134
+ if (node.type === "api-class" || node.type === "api-member") {
135
+ const name = node.props?.name || "";
136
+ const kind = node.props?.kind || "";
137
+ const depth = node.type === "api-class" ? 2 : 3;
138
+ const baseSlug = slugify(name);
139
+ const count = seen.get(baseSlug) ?? 0;
140
+ const slug = count === 0 ? baseSlug : `${baseSlug}-${count}`;
141
+ seen.set(baseSlug, count + 1);
142
+ headings.push({ depth, slug, text: name, kind });
143
+ // Persist the assigned slug on the node so format-astro's
144
+ // emitter can render the same id on the corresponding
145
+ // anchor — TOC links and on-page ids stay in sync.
146
+ if (!node.props)
147
+ node.props = {};
148
+ node.props.slug = slug;
149
+ }
150
+ if (node.children)
151
+ walk(node.children);
152
+ }
153
+ }
154
+ walk(page.tree);
155
+ if (headings.length > 0) {
156
+ page.headings = [...(page.headings ?? []), ...headings];
157
+ }
158
+ }
159
+ async function walkAndResolve(nodes, ctx) {
160
+ for (let i = 0; i < nodes.length; i++) {
161
+ const node = nodes[i];
162
+ if (node.type === "autodoc-ref") {
163
+ const replacement = await resolveOne(node, ctx);
164
+ if (replacement) {
165
+ nodes[i] = replacement;
166
+ }
167
+ continue;
168
+ }
169
+ if (node.children) {
170
+ await walkAndResolve(node.children, ctx);
171
+ }
172
+ }
173
+ }
174
+ async function resolveOne(node, ctx) {
175
+ const props = (node.props ?? {});
176
+ const ref = typeof props.ref === "string" ? props.ref : "";
177
+ const sourceRootRaw = typeof props.sourceRoot === "string" ? props.sourceRoot : "";
178
+ if (!ref) {
179
+ ctx.onFailed("(empty)", "no `ref` prop on :::autodoc directive");
180
+ return null;
181
+ }
182
+ if (!sourceRootRaw) {
183
+ ctx.onFailed(ref, "no `sourceRoot` prop on :::autodoc directive — needed to locate Python source");
184
+ return null;
185
+ }
186
+ // sourceRoot may be a path relative to dogsbay.config.yml.
187
+ // Resolve against configDir so the resolver doesn't depend on
188
+ // process.cwd().
189
+ const sourceRoot = resolvePath(ctx.configDir, sourceRootRaw);
190
+ // Pull rendering options from props, layered on top of the
191
+ // mkdocstrings-compatible defaults. tree-builder.ts uses
192
+ // truthy-checks for several of these (e.g. `if (options.showSource
193
+ // && symbol.sourceText) { ... }`) — without the defaults the
194
+ // collapsible source-code block, attribute signatures, and class
195
+ // bases all silently drop. The defaults mirror format-mkdocs/
196
+ // src/loader.ts → processAutodocDirectives so the migrated
197
+ // output matches the legacy import-mkdocs output by default.
198
+ const renderOptions = {
199
+ ...AUTODOC_RENDER_DEFAULTS,
200
+ ...props,
201
+ };
202
+ delete renderOptions.ref;
203
+ delete renderOptions.sourceRoot;
204
+ try {
205
+ const symbol = await resolveIdentifier(ref, sourceRoot);
206
+ if (!symbol) {
207
+ ctx.onFailed(ref, `identifier not found under ${sourceRoot}`);
208
+ return null;
209
+ }
210
+ const tree = symbolToTreeNode(symbol, renderOptions, ctx.md, ref, sourceRoot);
211
+ ctx.onResolved();
212
+ return tree;
213
+ }
214
+ catch (err) {
215
+ ctx.onFailed(ref, err.message);
216
+ return null;
217
+ }
218
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dogsbay",
3
- "version": "0.2.0-beta.39",
3
+ "version": "0.2.0-beta.40",
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,16 +32,18 @@
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.39",
36
- "@dogsbay/format-obsidian": "0.2.0-beta.39",
37
- "@dogsbay/format-astro": "0.2.0-beta.39",
38
- "@dogsbay/format-mdx": "0.2.0-beta.39",
39
- "@dogsbay/format-starlight": "0.2.0-beta.39",
40
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.39",
41
- "@dogsbay/format-openapi": "0.2.0-beta.39",
42
- "@dogsbay/types": "0.2.0-beta.39"
35
+ "@dogsbay/autodoc-python": "0.2.0-beta.38",
36
+ "@dogsbay/format-mkdocs": "0.2.0-beta.40",
37
+ "@dogsbay/format-astro": "0.2.0-beta.40",
38
+ "@dogsbay/format-obsidian": "0.2.0-beta.40",
39
+ "@dogsbay/format-starlight": "0.2.0-beta.40",
40
+ "@dogsbay/format-mdx": "0.2.0-beta.40",
41
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.40",
42
+ "@dogsbay/types": "0.2.0-beta.40",
43
+ "@dogsbay/format-openapi": "0.2.0-beta.40"
43
44
  },
44
45
  "devDependencies": {
46
+ "@types/markdown-it": "^14.1.0",
45
47
  "@types/node": "^22.0.0",
46
48
  "@types/prompts": "^2.4.9",
47
49
  "typescript": "^5.9.0",