dogsbay 0.2.0-beta.91 → 0.2.0-beta.93

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.
@@ -22,7 +22,7 @@ function getSourceDir() {
22
22
  return monorepo;
23
23
  throw new Error("Could not find component source files.");
24
24
  }
25
- function collectDependencies(names) {
25
+ export function collectDependencies(names) {
26
26
  const visited = new Set();
27
27
  const components = [];
28
28
  const npmDeps = [];
@@ -8,11 +8,13 @@
8
8
  * and runs `dogsbay site build`; the AsciiDoc source can be
9
9
  * deleted.
10
10
  *
11
- * Phase 8b of plans/format-asciidoc-import.md. Plain-corpus loader
12
- * only; topology-aware loaders (AsciiBinder `_topic_map.yml`,
13
- * Antora playbooks, AAP `master.adoc` walks) ship in Phase 9 per
14
- * plans/asciidoc-corpus-loaders.md and will hook in here without
15
- * changing the output shape.
11
+ * Corpus discovery is delegated to the `@dogsbay/format-asciidoc`
12
+ * CorpusLoader registry (see `asciidocPlugin.import` below), so the
13
+ * topology-aware loaders are live: detection auto-picks AsciiBinder
14
+ * (`_topic_maps/_topic_map.yml`), AAP (`master.adoc`), Antora component
15
+ * (`antora.yml`), or the plain `.adoc` walk, and topology files never
16
+ * accidentally become pages. Override with `--loader`.
17
+ * See plans/asciidoc-corpus-loaders.md.
16
18
  *
17
19
  * Output layout — same convention as `migrate-mkdocs`:
18
20
  *
@@ -29,10 +31,11 @@ import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, r
29
31
  import { basename, dirname, join, relative, resolve } from "node:path";
30
32
  import pc from "picocolors";
31
33
  import { emitSiteScaffold } from "@dogsbay/format-astro";
32
- import { plugin as asciidocPlugin } from "@dogsbay/format-asciidoc/cli";
34
+ import { plugin as asciidocPlugin, pickLoader } from "@dogsbay/format-asciidoc/cli";
33
35
  import { asciidocToMarkdown } from "@dogsbay/adoc2md-modular";
34
36
  import { treeToDogsbayMd } from "@dogsbay/format-dogsbay-md";
35
37
  import { ensureGitignore } from "../utils/gitignore.js";
38
+ import { inlineRemoteIncludes, hasRemoteIncludes } from "../utils/remote-includes.js";
36
39
  function parseAttributePairs(pairs) {
37
40
  const out = {};
38
41
  for (const raw of pairs ?? []) {
@@ -211,6 +214,9 @@ function convertFragmentAdocs(sourceRoot, outputContentDir, scopedRootDirs, page
211
214
  let converted = 0;
212
215
  let failed = 0;
213
216
  let skipped = 0;
217
+ // Fragment .md files that carry a `{% include "https://…" %}` directive —
218
+ // resolved by an async fetch pass after the (sync) walk completes. (#007)
219
+ const remoteIncludePaths = [];
214
220
  function walk(srcDir, dstDir, depth) {
215
221
  let entries;
216
222
  try {
@@ -302,6 +308,8 @@ function convertFragmentAdocs(sourceRoot, outputContentDir, scopedRootDirs, page
302
308
  // pursue option A through a non-frontmatter signal that
303
309
  // can't collide with Jinja whitespace control.
304
310
  writeFileSync(mdPath, md, "utf-8");
311
+ if (hasRemoteIncludes(md))
312
+ remoteIncludePaths.push(mdPath);
305
313
  converted += 1;
306
314
  }
307
315
  catch (err) {
@@ -312,7 +320,7 @@ function convertFragmentAdocs(sourceRoot, outputContentDir, scopedRootDirs, page
312
320
  }
313
321
  }
314
322
  walk(sourceRoot, outputContentDir, 0);
315
- return { converted, failed, skipped };
323
+ return { converted, failed, skipped, remoteIncludePaths };
316
324
  }
317
325
  /**
318
326
  * Serialize a NavItem[] (from a CorpusLoader) to nav.yml syntax.
@@ -400,6 +408,21 @@ function buildDogsbayConfig(opts) {
400
408
  return lines.join("\n");
401
409
  }
402
410
  function buildMigrationNotes(opts) {
411
+ // Report the loader that actually ran rather than assuming a plain
412
+ // walk. The non-plain loaders read a topology source for the nav and
413
+ // route only topology-declared pages; `modules/` / `snippets/` /
414
+ // `_attributes/` are pulled in as include targets, not pages.
415
+ const loaderDesc = {
416
+ asciibinder: "AsciiBinder topology (`_topic_maps/_topic_map.yml`)",
417
+ "master-adoc": "AAP `master.adoc` topology",
418
+ antora: "Antora component (`antora.yml`)",
419
+ plain: "plain AsciiDoc — flat `.adoc` walk",
420
+ };
421
+ const corpusDesc = loaderDesc[opts.loader] ?? `\`${opts.loader}\` loader`;
422
+ const distroNote = opts.distro ? `, distro \`${opts.distro}\`` : "";
423
+ const topologyDidnt = opts.loader === "plain"
424
+ ? "- **Topology**: the plain loader walked every `.adoc` flat and derived the nav from the directory layout. If your source is actually AsciiBinder (`_topic_maps/_topic_map.yml`), Antora (`antora.yml`), or AAP (`master.adoc`), re-run with `--loader <name>` (or rely on auto-detection) to use the curated topology and per-page distro filtering instead.\n"
425
+ : "";
403
426
  const hasAttrs = Object.keys(opts.attributes).length > 0;
404
427
  const attrSummary = hasAttrs
405
428
  ? Object.entries(opts.attributes)
@@ -416,7 +439,7 @@ Generated by \`dogsbay migrate-asciidoc\`.
416
439
  ## Summary
417
440
 
418
441
  - **Site name:** ${opts.siteName}
419
- - **Source corpus:** \`${opts.sourceDir}\` (plain AsciiDoc — \`.adoc\` walk)
442
+ - **Source corpus:** \`${opts.sourceDir}\` (${corpusDesc}${distroNote})
420
443
  - **Output:** \`${opts.outputDir}\`
421
444
  - **Pages migrated:** ${opts.pageCount}
422
445
 
@@ -455,8 +478,7 @@ See \`docs/asciidoc.md\` for the full preprocessor behaviour.
455
478
 
456
479
  ## What didn't (yet)
457
480
 
458
- - **Topology**: this migration walked every \`.adoc\` under \`${opts.sourceDir}\` as a flat plain-AsciiDoc corpus. If your source uses AsciiBinder \`_topic_map.yml\`, Antora \`antora-playbook.yml\`, or AAP \`master.adoc\` discovery, those topologies are not yet understood — the dedicated corpus loaders are planned for a future release (see \`plans/asciidoc-corpus-loaders.md\`).
459
- - **Line-form admonition layout**: \`NOTE: foo\` lines always emit the HTML-shaped \`**📌 NOTE**\\\` prefix regardless of options. Block-form \`[NOTE]\\n====\\n…\\n====\` does respect the configured \`admonitionFormat\`.
481
+ ${topologyDidnt}- **Line-form admonition layout**: \`NOTE: foo\` lines always emit the HTML-shaped \`**📌 NOTE**\\\` prefix regardless of options. Block-form \`[NOTE]\\n====\\n…\\n====\` does respect the configured \`admonitionFormat\`.
460
482
 
461
483
  ## Next steps
462
484
 
@@ -540,6 +562,7 @@ export async function migrateAsciidoc(source, options) {
540
562
  attributes: engineAttributes,
541
563
  loader: options.loader,
542
564
  distro: options.distro,
565
+ topicMap: options.topicMap,
543
566
  });
544
567
  if (importedPages.length === 0) {
545
568
  console.log(pc.red(`Error: no .adoc files found under ${sourceDir}`));
@@ -548,6 +571,13 @@ export async function migrateAsciidoc(source, options) {
548
571
  }
549
572
  console.log(`Pages: ${importedPages.length}`);
550
573
  console.log();
574
+ // Which loader did the import auto-pick? Re-run detection (cheap —
575
+ // filesystem existence checks) so MIGRATION.md reports the topology
576
+ // that actually ran, not a hardcoded "plain walk" string. The import
577
+ // above already succeeded, so this won't throw on a no-match.
578
+ const loaderName = pickLoader(sourceDir, options.loader, {
579
+ topicMap: options.topicMap,
580
+ }).name;
551
581
  // 3. Write each page to disk with title frontmatter.
552
582
  //
553
583
  // When the importer populated `page.bodyMarkdown` (currently only
@@ -620,6 +650,26 @@ export async function migrateAsciidoc(source, options) {
620
650
  (frag.skipped > 0 ? ` (${frag.skipped} already-written page paths skipped)` : "") +
621
651
  (frag.failed > 0 ? ` (${frag.failed} failed)` : ""));
622
652
  }
653
+ // 3c-bis. Resolve remote `include::https://…[]` directives: fetch each URL
654
+ // once and bake it into the fragment as a fenced code block, so the build
655
+ // has no runtime network dependency and the content isn't dropped. (#007)
656
+ if (frag.remoteIncludePaths.length > 0) {
657
+ const cache = new Map();
658
+ let inlined = 0;
659
+ for (const mdPath of frag.remoteIncludePaths) {
660
+ const before = readFileSync(mdPath, "utf-8");
661
+ const after = await inlineRemoteIncludes(before, { cache });
662
+ if (after !== before) {
663
+ writeFileSync(mdPath, after, "utf-8");
664
+ inlined += 1;
665
+ }
666
+ }
667
+ const failures = [...cache.values()].filter((v) => v === null).length;
668
+ console.log(pc.green(`Inlined`) +
669
+ ` remote includes in ${inlined} fragment(s) (${cache.size} URL(s)` +
670
+ (failures > 0 ? `, ${failures} unreachable` : "") +
671
+ `)`);
672
+ }
623
673
  // 3d. Mirror non-`.adoc` filesystem entries within the scoped
624
674
  // set: symlinks (critical for AsciiBinder include resolution
625
675
  // where per-section dirs symlink back to root `_attributes/`,
@@ -695,6 +745,8 @@ export async function migrateAsciidoc(source, options) {
695
745
  pageCount: importedPages.length,
696
746
  attributes,
697
747
  rerunCmd,
748
+ loader: loaderName,
749
+ distro: options.distro,
698
750
  }));
699
751
  console.log(pc.green(`Wrote`) + ` MIGRATION.md`);
700
752
  if (!options.quiet) {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `migrate-fluidtopics-rest` — migrate a *live* Fluid Topics portal (via the
3
+ * khub REST API) into a scaffolded Dogsbay-MD site. Reverse-engineers DITA-OT
4
+ * HTML5 → TreeNode → Dogsbay-MD. Works on any public FT portal.
5
+ *
6
+ * Same output shape as `migrate-fluidtopics` (Option A, Git Markdown export);
7
+ * this is Option B (live REST).
8
+ */
9
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
10
+ import { join, resolve, dirname } from "node:path";
11
+ import pc from "picocolors";
12
+ import YAML from "yaml";
13
+ import { emitSiteScaffold } from "@dogsbay/format-astro";
14
+ import { serializeConfig } from "../config/serialize.js";
15
+ export async function migrateFluidtopicsRest(source, options) {
16
+ if (!/^https?:\/\//i.test(source)) {
17
+ console.log(pc.red(`Error: source must be a portal URL (e.g. https://www.servicenow.com/docs)`));
18
+ process.exit(1);
19
+ }
20
+ const outputDir = resolve(options.output || "fluidtopics-rest-dogsbay");
21
+ const existingConfig = ["yml", "yaml", "json"]
22
+ .map((ext) => join(outputDir, `dogsbay.config.${ext}`))
23
+ .find(existsSync);
24
+ if (existingConfig && !options.force) {
25
+ console.log(pc.red(`Error: ${outputDir} already contains a Dogsbay site (${existingConfig}). Pass --force.`));
26
+ process.exit(1);
27
+ }
28
+ const siteName = options.siteName || "Documentation";
29
+ const basePath = options.basePath !== undefined ? options.basePath : "";
30
+ const hrefPrefix = basePath;
31
+ console.log();
32
+ console.log(pc.bold(`Migrating Fluid Topics portal (REST) to Dogsbay-MD: ${siteName}`));
33
+ console.log(`Portal: ${source}`);
34
+ console.log(`Output: ${outputDir}`);
35
+ console.log();
36
+ const contentDir = join(outputDir, "content");
37
+ const astroDir = join(outputDir, "astro");
38
+ mkdirSync(contentDir, { recursive: true });
39
+ mkdirSync(astroDir, { recursive: true });
40
+ const { importFluidTopicsRest } = (await import(
41
+ /* @vite-ignore */ "@dogsbay/format-fluidtopics-rest"));
42
+ const { treeToDogsbayMd } = (await import(
43
+ /* @vite-ignore */ "@dogsbay/format-dogsbay-md"));
44
+ const { pages, nav, unresolved, mapsProcessed, assets } = await importFluidTopicsRest({
45
+ portalBase: source,
46
+ maps: options.map,
47
+ titleFilter: options.titleFilter,
48
+ groupBy: options.groupBy,
49
+ singlePageBooks: options.singlePageBooks,
50
+ keepDuplicateMaps: options.keepDuplicateMaps,
51
+ metadataAllow: options.metadata?.split(",").map((s) => s.trim()).filter(Boolean),
52
+ fullTopicMetadata: options.fullTopicMetadata,
53
+ // Custom facets: editorialType (book/article), version, and subject (the
54
+ // DITA subjectScheme "Subject matter" / PBS facet, now lifted per-topic).
55
+ // category & audience are built-in. parseMeta lifts these into
56
+ // page.meta.taxonomies → DocsLayout emits data-pagefind-filter → Pagefind
57
+ // facets. (#search-facets)
58
+ taxonomyNames: ["editorialType", "version", "subject"],
59
+ lang: options.lang,
60
+ hrefPrefix,
61
+ throttleMs: options.throttle ? Number(options.throttle) : undefined,
62
+ singlePage: options.singlePage,
63
+ onProgress: (m) => console.log(pc.dim(` ${m}`)),
64
+ });
65
+ const unit = options.singlePage ? "page(s) (one per book)" : "topics";
66
+ console.log(pc.green(`Imported`) + ` ${pages.length} ${unit} from ${mapsProcessed} map(s)`);
67
+ // Write extracted images as real content-rooted assets (per _assets convention).
68
+ for (const a of assets) {
69
+ const dest = join(contentDir, a.path);
70
+ mkdirSync(dirname(dest), { recursive: true });
71
+ writeFileSync(dest, a.data);
72
+ }
73
+ if (assets.length)
74
+ console.log(pc.green(`Wrote`) + ` ${assets.length} images to content/_assets/`);
75
+ for (const page of pages) {
76
+ const dest = join(contentDir, `${page.slug || "index"}.md`);
77
+ mkdirSync(dirname(dest), { recursive: true });
78
+ const body = treeToDogsbayMd(page.tree, { frontmatter: page.frontmatter });
79
+ writeFileSync(dest, body.endsWith("\n") ? body : `${body}\n`);
80
+ }
81
+ console.log(pc.green(`Wrote`) + ` ${pages.length} pages to ./content/`);
82
+ writeFileSync(join(contentDir, "nav.yml"), YAML.stringify(navItemsToSingleKey(nav, basePath)));
83
+ console.log(pc.green(`Wrote`) + ` content/nav.yml (${nav.length} top-level items)`);
84
+ const config = {
85
+ schemaVersion: 1,
86
+ site: { name: siteName, url: options.siteUrl, basePath },
87
+ content: { sources: [{ path: "./content", from: "dogsbay-md" }] },
88
+ // FT faceted metadata → search filters. `category` / `audience` are
89
+ // built-in facets (no declaration needed); `editorialType` (book/article),
90
+ // `version`, and `subject` (the DITA subjectScheme "Subject matter" / PBS
91
+ // facet) are declared here so the build extracts them into each page's
92
+ // taxonomies, DocsLayout emits the data-pagefind-filter divs, and Pagefind
93
+ // surfaces them as facet columns. Also gives browseable term index pages
94
+ // (/format, /version, /subject). See docs-dev/fluidtopics-rest-importer.md.
95
+ taxonomies: {
96
+ editorialType: { indexPath: "/format", order: 2 },
97
+ version: { indexPath: "/version", order: 3 },
98
+ subject: { indexPath: "/subject", order: 4 },
99
+ },
100
+ agent: { llmsTxt: true, mdMirror: true },
101
+ };
102
+ writeFileSync(join(outputDir, "dogsbay.config.yml"), serializeConfig(config, "yaml"));
103
+ emitSiteScaffold(astroDir, siteName, { siteName, siteUrl: options.siteUrl, basePath, llmsTxt: true, mdMirror: true, local: options.local }, true);
104
+ const unresolvedCount = Object.values(unresolved).reduce((n, a) => n + a.length, 0);
105
+ writeFileSync(join(outputDir, "MIGRATION.md"), `# Migration notes — ${siteName} (Fluid Topics REST)\n\nMigrated live from \`${source}\` via the khub API; DITA-OT HTML5 reverse-engineered to Dogsbay-MD.\n\n- Maps: **${mapsProcessed}**\n- Pages: **${pages.length}**\n- Unresolved in-content links: **${unresolvedCount}**\n\nMedia and any conref/keyref/profiling were already resolved/flattened upstream by FT's DITA-OT run.\n`);
106
+ console.log(pc.green(`Scaffolded`) + ` Astro project + config + MIGRATION.md`);
107
+ if (!options.quiet) {
108
+ console.log();
109
+ console.log(pc.bold("Done."));
110
+ console.log(` cd ${outputDir} && npx dogsbay site dev`);
111
+ }
112
+ }
113
+ function navItemsToSingleKey(items, basePath) {
114
+ const out = [];
115
+ for (const item of items) {
116
+ // A node carrying a single-page annotation uses the EXPLICIT nav form
117
+ // (`{ title, single-page, items }`) so the annotation survives into nav.yml;
118
+ // unannotated nodes keep the compact single-key form.
119
+ if (item.singlePage) {
120
+ const entry = { title: item.label, "single-page": item.singlePage };
121
+ if (item.children && item.children.length > 0) {
122
+ entry.items = navItemsToSingleKey(item.children, basePath);
123
+ }
124
+ else if (item.href) {
125
+ entry.file = hrefToNavPath(item.href, basePath);
126
+ }
127
+ out.push(entry);
128
+ }
129
+ else if (item.children && item.children.length > 0) {
130
+ out.push({ [item.label]: navItemsToSingleKey(item.children, basePath) });
131
+ }
132
+ else if (item.href) {
133
+ out.push({ [item.label]: hrefToNavPath(item.href, basePath) });
134
+ }
135
+ else {
136
+ out.push({ [item.label]: [] });
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+ function hrefToNavPath(href, basePath) {
142
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href))
143
+ return href;
144
+ let s = href.split("#")[0];
145
+ if (basePath && s.startsWith(basePath))
146
+ s = s.slice(basePath.length);
147
+ s = s.replace(/^\/+/, "");
148
+ return s ? `${s}.md` : "index.md";
149
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `migrate-fluidtopics` — one-way migrate a Fluid Topics Markdown export
3
+ * (e.g. the ServiceNow/ServiceNowDocs repo) into a scaffolded Dogsbay-MD site.
4
+ *
5
+ * Canonical migration layout (see the migration-shape skill):
6
+ * <output>/
7
+ * content/ <- Dogsbay-MD, the new source of truth
8
+ * <pub>/<topic>.md
9
+ * nav.yml
10
+ * astro/ <- generated Astro project (emitSiteScaffold)
11
+ * dogsbay.config.yml
12
+ * MIGRATION.md
13
+ *
14
+ * Mirrors `migrate-mkdocs`, reusing the shared emitters
15
+ * (importer -> treeToDogsbayMd -> emitSiteScaffold + serializeConfig).
16
+ */
17
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
18
+ import { join, resolve, basename, dirname } from "node:path";
19
+ import pc from "picocolors";
20
+ import YAML from "yaml";
21
+ import { emitSiteScaffold } from "@dogsbay/format-astro";
22
+ import { serializeConfig } from "../config/serialize.js";
23
+ export async function migrateFluidtopics(source, options) {
24
+ const sourceDir = resolve(source);
25
+ const markdownDir = join(sourceDir, "markdown");
26
+ if (!existsSync(markdownDir)) {
27
+ console.log(pc.red(`Error: no markdown/ directory found in ${sourceDir}`));
28
+ console.log("Point this at a Fluid Topics Markdown export (e.g. a ServiceNowDocs checkout).");
29
+ process.exit(1);
30
+ }
31
+ const outputDir = resolve(options.output || `${basename(sourceDir)}-dogsbay`);
32
+ const existingConfig = ["yml", "yaml", "json"]
33
+ .map((ext) => join(outputDir, `dogsbay.config.${ext}`))
34
+ .find(existsSync);
35
+ if (existingConfig && !options.force) {
36
+ console.log(pc.red(`Error: ${outputDir} already contains a Dogsbay site (${basename(existingConfig)}).`));
37
+ console.log("Pass --force to overwrite.");
38
+ process.exit(1);
39
+ }
40
+ const siteName = options.siteName || "Documentation";
41
+ // A dedicated docs repo serves at the host root by default (matches the
42
+ // openshift-docs-dogsbay / GitHub Pages project-site convention).
43
+ const basePath = options.basePath !== undefined ? options.basePath : "";
44
+ const hrefPrefix = basePath; // root-relative slugs when basePath is ""
45
+ console.log();
46
+ console.log(pc.bold(`Migrating Fluid Topics Markdown export to Dogsbay-MD: ${siteName}`));
47
+ console.log(`Source: ${sourceDir}`);
48
+ console.log(`Output: ${outputDir}`);
49
+ console.log();
50
+ const contentDir = join(outputDir, "content");
51
+ const astroDir = join(outputDir, "astro");
52
+ mkdirSync(contentDir, { recursive: true });
53
+ mkdirSync(astroDir, { recursive: true });
54
+ // 1. Import the export -> pages + nav.
55
+ const { importFluidTopicsProject } = (await import(
56
+ /* @vite-ignore */ "@dogsbay/format-fluidtopics-markdown"));
57
+ const { treeToDogsbayMd } = (await import(
58
+ /* @vite-ignore */ "@dogsbay/format-dogsbay-md"));
59
+ const { pages, nav, unresolved } = await importFluidTopicsProject(sourceDir, {
60
+ hrefPrefix,
61
+ publications: options.publication,
62
+ });
63
+ if (options.publication && options.publication.length > 0) {
64
+ console.log(pc.dim(`Scoped to publications: ${options.publication.join(", ")}`));
65
+ }
66
+ // 2. Write each page to ./content/<slug>.md as Dogsbay-MD.
67
+ for (const page of pages) {
68
+ const rel = `${page.slug || "index"}.md`;
69
+ const dest = join(contentDir, rel);
70
+ mkdirSync(dirname(dest), { recursive: true });
71
+ const body = treeToDogsbayMd(page.tree, { frontmatter: page.frontmatter });
72
+ writeFileSync(dest, body.endsWith("\n") ? body : `${body}\n`);
73
+ }
74
+ console.log(pc.green("Wrote") + ` ${pages.length} pages to ./content/ as Dogsbay-MD`);
75
+ // 3. nav.yml (single-key-map; file paths relative to content/).
76
+ const navYml = navItemsToSingleKey(nav, basePath);
77
+ writeFileSync(join(contentDir, "nav.yml"), YAML.stringify(navYml));
78
+ console.log(pc.green("Extracted") + ` navigation to content/nav.yml (${navYml.length} top-level items)`);
79
+ // 4. dogsbay.config.yml.
80
+ const dogsbayConfig = {
81
+ schemaVersion: 1,
82
+ site: { name: siteName, url: options.siteUrl, basePath },
83
+ content: { sources: [{ path: "./content", from: "dogsbay-md" }] },
84
+ agent: { llmsTxt: true, mdMirror: true },
85
+ };
86
+ writeFileSync(join(outputDir, "dogsbay.config.yml"), serializeConfig(dogsbayConfig, "yaml"));
87
+ console.log(pc.green("Wrote") + " dogsbay.config.yml");
88
+ // 5. Scaffold the Astro project under ./astro/.
89
+ emitSiteScaffold(astroDir, siteName, { siteName, siteUrl: options.siteUrl, basePath, llmsTxt: true, mdMirror: true, local: options.local }, true);
90
+ console.log(pc.green("Scaffolded") + " Astro project at ./astro/");
91
+ // 6. MIGRATION.md.
92
+ const unresolvedCount = Object.values(unresolved).reduce((n, a) => n + a.length, 0);
93
+ writeFileSync(join(outputDir, "MIGRATION.md"), buildMigrationNotes({ sourceDir, siteName, pageCount: pages.length, unresolvedCount, basePath }));
94
+ console.log(pc.green("Wrote") + " MIGRATION.md");
95
+ if (!options.quiet) {
96
+ console.log();
97
+ console.log(pc.bold("Done! Your Dogsbay-MD site is ready."));
98
+ console.log();
99
+ console.log("Next steps:");
100
+ console.log(` cd ${outputDir}`);
101
+ console.log(" npx dogsbay site dev # live preview (auto-installs)");
102
+ console.log();
103
+ console.log("Edit ./content/*.md (canonical Dogsbay-MD) from here on.");
104
+ }
105
+ }
106
+ function navItemsToSingleKey(items, basePath) {
107
+ const out = [];
108
+ for (const item of items) {
109
+ if (item.children && item.children.length > 0) {
110
+ out.push({ [item.label]: navItemsToSingleKey(item.children, basePath) });
111
+ }
112
+ else if (item.href) {
113
+ out.push({ [item.label]: hrefToNavPath(item.href, basePath) });
114
+ }
115
+ else {
116
+ out.push({ [item.label]: [] });
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+ /** Convert a resolved route href back to a content-relative .md path. */
122
+ function hrefToNavPath(href, basePath) {
123
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href))
124
+ return href; // external
125
+ let s = href.split("#")[0];
126
+ if (basePath && s.startsWith(basePath))
127
+ s = s.slice(basePath.length);
128
+ s = s.replace(/^\/+/, "");
129
+ if (!s)
130
+ return "index.md";
131
+ return `${s}.md`;
132
+ }
133
+ function buildMigrationNotes(args) {
134
+ return [
135
+ `# Migration notes — ${args.siteName}`,
136
+ "",
137
+ `Migrated from a Fluid Topics Markdown export at \`${args.sourceDir}\`.`,
138
+ "",
139
+ `- Pages: **${args.pageCount}**`,
140
+ `- Base path: \`${args.basePath || "(root)"}\``,
141
+ `- Unresolved internal links: **${args.unresolvedCount}** (broken cross-references in the source export are left as-is).`,
142
+ "",
143
+ "## What this did",
144
+ "",
145
+ "- Converted each topic to Dogsbay-MD under `./content/` (DITA `topic_type` → frontmatter `type`).",
146
+ "- Embedded HTML tables → Markdown tables; DITA nav-card grids → `:::cards`.",
147
+ "- Resolved both link schemes (relative `.md` + `raw.githubusercontent` TOC URLs) to routes.",
148
+ "- Built `content/nav.yml` from each publication's `index.md` TOC + the README order.",
149
+ "",
150
+ "## Known gaps",
151
+ "",
152
+ "- **Media is omitted** in the source export — image references may be dangling. Pull assets from the live portal or replace as needed.",
153
+ "- Conref/keyref/profiling were already resolved/flattened by the export (not recoverable here).",
154
+ "",
155
+ "Edit `./content/*.md` from here on; re-run the migration to refresh.",
156
+ "",
157
+ ].join("\n");
158
+ }
@@ -104,6 +104,15 @@ function validate(parsed, sourcePath) {
104
104
  const propSchema = validatePropSchema(obj.propSchema, sourcePath);
105
105
  const plugins = validatePlugins(obj.plugins, sourcePath);
106
106
  const attributes = validateAttributes(obj.attributes, "attributes", sourcePath);
107
+ if (obj.granularity !== undefined) {
108
+ if (typeof obj.granularity !== "object" || obj.granularity === null) {
109
+ throw new Error(`granularity must be an object in ${sourcePath}`);
110
+ }
111
+ const g = obj.granularity;
112
+ if (g.book !== undefined && typeof g.book !== "boolean") {
113
+ throw new Error(`granularity.book must be a boolean in ${sourcePath}`);
114
+ }
115
+ }
107
116
  // Pass through validated structure — applyDefaults handles missing
108
117
  // optionals and contentSignal sub-fields.
109
118
  return {
@@ -121,6 +130,7 @@ function validate(parsed, sourcePath) {
121
130
  propSchema,
122
131
  plugins,
123
132
  attributes,
133
+ granularity: obj.granularity,
124
134
  };
125
135
  }
126
136
  /**
@@ -29,6 +29,7 @@ export function configToAstroOptions(config) {
29
29
  navMode: config.build?.navMode,
30
30
  llmsTxt: config.agent?.llmsTxt,
31
31
  mdMirror: config.agent?.mdMirror,
32
+ granularity: config.granularity,
32
33
  aiTrain: config.agent?.contentSignal?.aiTrain,
33
34
  aiInput: config.agent?.contentSignal?.aiInput,
34
35
  aiSearch: config.agent?.contentSignal?.search,
package/dist/index.js CHANGED
@@ -8,6 +8,8 @@ import { add, list } from "./commands/add.js";
8
8
  import { importMkdocs } from "./commands/import-mkdocs.js";
9
9
  import { migrateMkdocs } from "./commands/migrate-mkdocs.js";
10
10
  import { migrateAsciidoc } from "./commands/migrate-asciidoc.js";
11
+ import { migrateFluidtopics } from "./commands/migrate-fluidtopics.js";
12
+ import { migrateFluidtopicsRest } from "./commands/migrate-fluidtopics-rest.js";
11
13
  import { preprocessVariants } from "./commands/preprocess-variants.js";
12
14
  import { lighthouse } from "./commands/lighthouse.js";
13
15
  import { convert } from "./commands/convert.js";
@@ -211,10 +213,11 @@ program
211
213
  .action((source, options) => migrateMkdocs(source, options));
212
214
  program
213
215
  .command("migrate-asciidoc")
214
- .description("One-way migrate a plain AsciiDoc corpus to a scaffolded Dogsbay-MD " +
216
+ .description("One-way migrate an AsciiDoc corpus to a scaffolded Dogsbay-MD " +
215
217
  "project. Source-of-truth becomes ./content/*.md; the AsciiDoc source " +
216
- "can be deleted. Plain .adoc directory walk only — AsciiBinder, " +
217
- "Antora, and AAP master.adoc loaders are planned for a later release.")
218
+ "can be deleted. Topology is auto-detected — AsciiBinder " +
219
+ "(_topic_maps/_topic_map.yml), AAP (master.adoc), Antora (antora.yml), " +
220
+ "or a plain .adoc directory walk; override with --loader.")
218
221
  .argument("<source>", "Path to a directory containing .adoc files")
219
222
  .option("-o, --output <dir>", "Output directory (default: {source}-dogsbay)")
220
223
  .option("--force", "Overwrite an existing Dogsbay site. Clean re-import: clears the " +
@@ -226,13 +229,59 @@ program
226
229
  .option("-a, --attribute <name=value>", "AsciiDoc attribute (repeatable). Resolved refs/conditionals get " +
227
230
  "baked into the migrated Dogsbay-MD; unresolved ones survive as " +
228
231
  "{{ var }} / {% if %} for a downstream Minja pass.", (val, prev = []) => [...prev, val])
229
- .option("--loader <name>", "Force a corpus loader (plain, master-adoc). Auto-detected when omitted.")
232
+ .option("--loader <name>", "Force a corpus loader (plain, master-adoc, asciibinder, antora). " +
233
+ "Auto-detected when omitted.")
230
234
  .option("--distro <name>", "AsciiBinder distro filter (e.g. 'openshift-enterprise'). Topic-map " +
231
235
  "entries whose Distros: list excludes this value are dropped from " +
232
236
  "the page set and nav. Entries without Distros: are kept as universal.")
237
+ .option("--topic-map <path>", "AsciiBinder topic-map path, relative to the source root " +
238
+ "(default: _topic_maps/_topic_map.yml). Drives loader detection and " +
239
+ "loading for corpora that name their topic map differently.")
233
240
  .option("--local", "Use file: references to local monorepo packages (for development)")
234
241
  .option("--quiet", "Suppress the 'Next steps' trailer")
235
242
  .action((source, options) => migrateAsciidoc(source, options));
243
+ program
244
+ .command("migrate-fluidtopics")
245
+ .description("One-way migrate a Fluid Topics Markdown export (e.g. a ServiceNowDocs " +
246
+ "checkout: markdown/{publication}/**) to a scaffolded Dogsbay-MD " +
247
+ "project. Source-of-truth becomes ./content/*.md.")
248
+ .argument("<source>", "Path to the FT Markdown export (containing markdown/)")
249
+ .option("-o, --output <dir>", "Output directory (default: {source}-dogsbay)")
250
+ .option("--force", "Overwrite an existing Dogsbay site at the output dir")
251
+ .option("--site-name <name>", "Site name written into dogsbay.config.yml")
252
+ .option("--site-url <url>", "Canonical URL of the destination site. Drives robots.txt, sitemap, " +
253
+ "canonical tags, and llms.txt.")
254
+ .option("--base-path <path>", 'Path under the host where docs are served (default "" = host root, ' +
255
+ "the dedicated-repo convention).")
256
+ .option("--publication <name>", "Limit the migration to one or more publications (top-level markdown/{pub} " +
257
+ "folder). Repeatable. Useful for previewing a slice of a huge corpus.", (val, prev = []) => [...prev, val])
258
+ .option("--local", "Use file: references to local monorepo packages (for development)")
259
+ .option("--quiet", "Suppress the 'Next steps' trailer")
260
+ .action((source, options) => migrateFluidtopics(source, options));
261
+ program
262
+ .command("migrate-fluidtopics-rest")
263
+ .description("Migrate a LIVE Fluid Topics portal via the khub REST API (Option B): " +
264
+ "reverse-engineers DITA-OT HTML5 → Dogsbay-MD. Works on any public FT " +
265
+ "portal (ServiceNow, Johnson Controls, FT docs).")
266
+ .argument("<portal-url>", "FT portal root (e.g. https://www.servicenow.com/docs or https://docs.johnsoncontrols.com/bas)")
267
+ .option("-o, --output <dir>", "Output directory (default: fluidtopics-rest-dogsbay)")
268
+ .option("--force", "Overwrite an existing Dogsbay site at the output dir")
269
+ .option("--site-name <name>", "Site name written into dogsbay.config.yml")
270
+ .option("--site-url <url>", "Canonical URL of the destination site")
271
+ .option("--base-path <path>", 'Path under the host where docs are served (default "" = host root)')
272
+ .option("--map <id>", "Limit to one or more map ids (repeatable)", (v, p = []) => [...p, v])
273
+ .option("--title-filter <substr>", "Limit to maps whose title contains this substring (case-insensitive)")
274
+ .option("--lang <locale>", "Locale to request from the portal (e.g. en-US)")
275
+ .option("--throttle <ms>", "Delay between khub requests (default 150)")
276
+ .option("--single-page", "Roll each map into ONE page (whole book) instead of one page per topic; xrefs become in-page anchors")
277
+ .option("--group-by <facets>", "Group the nav by map metadata facet(s), comma-separated (e.g. Category or Product,Category). Default: auto (Category). Use 'none' to keep it flat")
278
+ .option("--single-page-books", "Mark book-type maps (editorialType=book) with single-page: yes — an additive 'Read as single page' rollup per guide; articles stay per-topic (size cap applies)")
279
+ .option("--keep-duplicate-maps", "Keep stale duplicate publications (same title+version). Default: keep only the freshest ft:lastEdition per guide, removing duplicate nav entries")
280
+ .option("--metadata <keys>", "Extra FT topic-metadata keys to lift into page frontmatter, comma-separated (beyond the curated subject/product/author/category/audience/version set)")
281
+ .option("--full-topic-metadata", "Fetch full per-topic metadata (one request per topic) for accurate per-topic ft:wordCount/ft:outputSize + 'updated' date. Roughly doubles requests; off by default (technical numerics emit at book level only)")
282
+ .option("--local", "Use file: references to local monorepo packages (for development)")
283
+ .option("--quiet", "Suppress the trailer")
284
+ .action((source, options) => migrateFluidtopicsRest(source, options));
236
285
  program
237
286
  .command("preprocess-variants")
238
287
  .description("Add variant tabs to file includes (e.g. Python 3.10+ / 3.9+ tabs)")
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Remote AsciiDoc include resolution (migrate time).
3
+ *
4
+ * AsciiDoc `include::https://…[]` inlines remote content at build time
5
+ * (AsciiBinder runs with `--allow-uri-read`). The adoc2md engine — which is
6
+ * pure and does no network I/O — emits the URL form as a Minja directive
7
+ * `{% include "./https://…" %}`. The build-time Minja loader is filesystem-only,
8
+ * so the directive would leak verbatim into a `<pre>` block, dropping the
9
+ * content (e.g. 6 CloudFormation templates on the AWS UPI install pages).
10
+ *
11
+ * This pass fetches each remote include ONCE (cached by URL) and inlines it as
12
+ * a fenced code block, baking the content into the migrated markdown so the
13
+ * build has no runtime network dependency. On fetch failure it leaves a visible
14
+ * link instead of a broken directive. Issue #007.
15
+ */
16
+ // Code-fence language inferred from the URL's file extension.
17
+ const LANG_BY_EXT = {
18
+ yaml: "yaml",
19
+ yml: "yaml",
20
+ json: "json",
21
+ sh: "bash",
22
+ bash: "bash",
23
+ py: "python",
24
+ rb: "ruby",
25
+ go: "go",
26
+ toml: "toml",
27
+ ini: "ini",
28
+ xml: "xml",
29
+ tf: "hcl",
30
+ };
31
+ // `{% include "./https://…" %}` (the `./` is the engine's local-path prefix,
32
+ // spurious for URLs) on its own line, capturing leading indent + the URL.
33
+ const remoteIncludeRx = /^([ \t]*)\{%\s*include\s*"\.?\/?(https?:\/\/[^"]+)"\s*%\}[ \t]*$/gm;
34
+ function langForUrl(url) {
35
+ const clean = url.split(/[?#]/)[0];
36
+ const ext = clean.slice(clean.lastIndexOf(".") + 1).toLowerCase();
37
+ return LANG_BY_EXT[ext] ?? "";
38
+ }
39
+ /** Choose a backtick fence longer than any run inside the content. */
40
+ function pickFence(content) {
41
+ const longest = (content.match(/`+/g) ?? []).reduce((n, run) => Math.max(n, run.length), 0);
42
+ return "`".repeat(Math.max(3, longest + 1));
43
+ }
44
+ /** True if `md` contains at least one remote include directive. */
45
+ export function hasRemoteIncludes(md) {
46
+ remoteIncludeRx.lastIndex = 0;
47
+ return remoteIncludeRx.test(md);
48
+ }
49
+ /**
50
+ * Replace every `{% include "https://…" %}` directive in `md` with the fetched
51
+ * content as a fenced code block. Returns `md` unchanged if there are none.
52
+ */
53
+ export async function inlineRemoteIncludes(md, options = {}) {
54
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
55
+ const cache = options.cache ?? new Map();
56
+ const onWarn = options.onWarn ?? ((m) => console.warn(m));
57
+ // Collect the distinct URLs first so each is fetched at most once.
58
+ const urls = new Set();
59
+ for (const m of md.matchAll(remoteIncludeRx))
60
+ urls.add(m[2]);
61
+ if (urls.size === 0)
62
+ return md;
63
+ for (const url of urls) {
64
+ if (cache.has(url))
65
+ continue;
66
+ try {
67
+ const res = await fetchImpl(url);
68
+ if (!res.ok)
69
+ throw new Error(`HTTP ${res.status}`);
70
+ cache.set(url, await res.text());
71
+ }
72
+ catch (err) {
73
+ cache.set(url, null);
74
+ onWarn(`remote include: failed to fetch ${url} (${err instanceof Error ? err.message : String(err)}); leaving a link`);
75
+ }
76
+ }
77
+ return md.replace(remoteIncludeRx, (_whole, indent, url) => {
78
+ const content = cache.get(url);
79
+ if (content == null) {
80
+ // Fetch failed (or not attempted): a visible link beats a broken directive.
81
+ return `${indent}See [${url}](${url}).`;
82
+ }
83
+ const body = content.replace(/\n$/, "");
84
+ const fence = pickFence(body);
85
+ const lang = langForUrl(url);
86
+ return `${indent}${fence}${lang}\n${body}\n${indent}${fence}`;
87
+ });
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dogsbay",
3
- "version": "0.2.0-beta.91",
3
+ "version": "0.2.0-beta.93",
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": {
@@ -33,20 +33,22 @@
33
33
  "picocolors": "^1.1.0",
34
34
  "prompts": "^2.4.2",
35
35
  "yaml": "^2.8.3",
36
- "@dogsbay/autodoc-python": "0.2.0-beta.91",
37
- "@dogsbay/format-mkdocs": "0.2.0-beta.91",
38
- "@dogsbay/format-astro": "0.2.0-beta.91",
39
- "@dogsbay/format-obsidian": "0.2.0-beta.91",
40
- "@dogsbay/format-mdx": "0.2.0-beta.91",
41
- "@dogsbay/format-starlight": "0.2.0-beta.91",
42
- "@dogsbay/format-docusaurus": "0.2.0-beta.91",
43
- "@dogsbay/docusaurus-adapter-tigera": "0.2.0-beta.91",
44
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.91",
45
- "@dogsbay/format-openapi": "0.2.0-beta.91",
46
- "@dogsbay/adoc2md-modular": "0.2.0-beta.91",
47
- "@dogsbay/format-asciidoc": "0.2.0-beta.91",
48
- "@dogsbay/minja": "0.2.0-beta.91",
49
- "@dogsbay/types": "0.2.0-beta.91"
36
+ "@dogsbay/format-mkdocs": "0.2.0-beta.93",
37
+ "@dogsbay/autodoc-python": "0.2.0-beta.93",
38
+ "@dogsbay/format-astro": "0.2.0-beta.93",
39
+ "@dogsbay/format-obsidian": "0.2.0-beta.93",
40
+ "@dogsbay/format-mdx": "0.2.0-beta.93",
41
+ "@dogsbay/format-starlight": "0.2.0-beta.93",
42
+ "@dogsbay/format-docusaurus": "0.2.0-beta.93",
43
+ "@dogsbay/docusaurus-adapter-tigera": "0.2.0-beta.93",
44
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.93",
45
+ "@dogsbay/format-fluidtopics-markdown": "0.2.0-beta.93",
46
+ "@dogsbay/format-openapi": "0.2.0-beta.93",
47
+ "@dogsbay/format-fluidtopics-rest": "0.2.0-beta.93",
48
+ "@dogsbay/adoc2md-modular": "0.2.0-beta.93",
49
+ "@dogsbay/format-asciidoc": "0.2.0-beta.93",
50
+ "@dogsbay/minja": "0.2.0-beta.93",
51
+ "@dogsbay/types": "0.2.0-beta.93"
50
52
  },
51
53
  "devDependencies": {
52
54
  "@types/markdown-it": "^14.1.0",