dogsbay 0.2.0-beta.110 → 0.2.0-beta.112

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.
@@ -0,0 +1,27 @@
1
+ export interface ResolveOptions {
2
+ out: string;
3
+ config?: string;
4
+ attribute?: string[];
5
+ deliverable?: string;
6
+ undefinedRefs?: "preserve" | "asciidoc-literal";
7
+ }
8
+ /**
9
+ * `dogsbay resolve` — the resolution STAGE as a verb. Dogsbay MD in, Dogsbay
10
+ * MD out: `{{ var }}` substituted, `{% if %}` branches chosen, `{% include %}`
11
+ * spliced, and the directive vocabulary (`:::note`, tabs, steps, …) left
12
+ * exactly as authored. It is not a format: the templated form stays the
13
+ * canonical one, this is a per-variant snapshot of it. See plans/cli-grammar.md.
14
+ *
15
+ * Two shapes of input, decided by whether the directory ITSELF carries a
16
+ * config (an ancestor's config is not used — running `resolve ./markdown`
17
+ * inside a site must resolve that directory, not whatever the site declares):
18
+ *
19
+ * - a site root → `site build --to dogsbay-md`, so sources, `attributes:`,
20
+ * `undefinedRefs`, deliverables and include policy all come from the config
21
+ * and the output is byte-identical to the build's own mirror;
22
+ * - a bare directory → the preprocessor over that tree with only the `-a`
23
+ * attributes. Partial evaluation (`--only includes`, keeping unsupplied
24
+ * attributes as template text) is planned, not built — today an undefined
25
+ * reference follows `--undefined-refs`.
26
+ */
27
+ export declare function resolveCommand(dir: string | undefined, options: ResolveOptions): Promise<void>;
@@ -0,0 +1,79 @@
1
+ import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import pc from "picocolors";
5
+ import { findConfig } from "../config/index.js";
6
+ import { runPreprocessor } from "../site-build/preprocess.js";
7
+ import { parseAttributePairs, siteBuild } from "./site-build.js";
8
+ /**
9
+ * `dogsbay resolve` — the resolution STAGE as a verb. Dogsbay MD in, Dogsbay
10
+ * MD out: `{{ var }}` substituted, `{% if %}` branches chosen, `{% include %}`
11
+ * spliced, and the directive vocabulary (`:::note`, tabs, steps, …) left
12
+ * exactly as authored. It is not a format: the templated form stays the
13
+ * canonical one, this is a per-variant snapshot of it. See plans/cli-grammar.md.
14
+ *
15
+ * Two shapes of input, decided by whether the directory ITSELF carries a
16
+ * config (an ancestor's config is not used — running `resolve ./markdown`
17
+ * inside a site must resolve that directory, not whatever the site declares):
18
+ *
19
+ * - a site root → `site build --to dogsbay-md`, so sources, `attributes:`,
20
+ * `undefinedRefs`, deliverables and include policy all come from the config
21
+ * and the output is byte-identical to the build's own mirror;
22
+ * - a bare directory → the preprocessor over that tree with only the `-a`
23
+ * attributes. Partial evaluation (`--only includes`, keeping unsupplied
24
+ * attributes as template text) is planned, not built — today an undefined
25
+ * reference follows `--undefined-refs`.
26
+ */
27
+ export async function resolveCommand(dir, options) {
28
+ const start = resolve(dir ?? ".");
29
+ const explicit = options.config ? resolve(options.config) : undefined;
30
+ const found = explicit ?? findConfig(start);
31
+ const configHere = found !== null && found !== undefined && (explicit !== undefined || dirname(found) === start);
32
+ if (configHere) {
33
+ if (options.undefinedRefs) {
34
+ throw new Error("--undefined-refs applies to a bare directory; with a config, set `undefinedRefs:` there.");
35
+ }
36
+ console.log(pc.bold(`Resolving ${start} (config: ${found})`));
37
+ await siteBuild(start, {
38
+ config: options.config,
39
+ to: ["dogsbay-md"],
40
+ out: options.out,
41
+ attribute: options.attribute,
42
+ deliverable: options.deliverable,
43
+ });
44
+ return;
45
+ }
46
+ if (!existsSync(start))
47
+ throw new Error(`No such directory: ${start}`);
48
+ if (options.deliverable) {
49
+ throw new Error("--deliverable needs a dogsbay.config.yml in the directory being resolved.");
50
+ }
51
+ const out = resolve(options.out);
52
+ const inside = (parent, child) => child === parent || child.startsWith(parent.endsWith("/") ? parent : parent + "/");
53
+ if (inside(out, start) || inside(start, out)) {
54
+ throw new Error(`refusing to write the resolved Markdown to ${out}: it is inside, or contains, the source ${start}. ` +
55
+ `To fix: choose an output directory outside it.`);
56
+ }
57
+ console.log(pc.bold(`Resolving ${start} (no config — attributes from -a only)`));
58
+ // The preprocessor writes per-source subdirectories under a root and wipes
59
+ // its own subdirectory; stage under a temp root and move the one tree to
60
+ // where the user asked, so `-o` names the tree and nothing else.
61
+ const staging = mkdtempSync(join(tmpdir(), "dogsbay-resolve-"));
62
+ try {
63
+ const result = await runPreprocessor([{ resolvedPath: start, attributes: parseAttributePairs(options.attribute) }], { mirrorRoot: staging, undefinedRefs: options.undefinedRefs });
64
+ rmSync(out, { recursive: true, force: true });
65
+ cpSync(result.mirrorPaths[0], out, { recursive: true });
66
+ const parts = [`${result.filesProcessed} resolved`, `${result.filesUnchanged} unchanged`];
67
+ if (result.filesOptedOut > 0)
68
+ parts.push(`${result.filesOptedOut} opted out`);
69
+ if (result.pagesFailedRender.length > 0)
70
+ parts.push(`${result.pagesFailedRender.length} FAILED (copied raw)`);
71
+ if (result.pagesWithUnresolvedIncludes.length > 0) {
72
+ parts.push(`${result.pagesWithUnresolvedIncludes.length} with unresolved includes`);
73
+ }
74
+ console.log(` ${parts.join(", ")} → ${out}`);
75
+ }
76
+ finally {
77
+ rmSync(staging, { recursive: true, force: true });
78
+ }
79
+ }
@@ -1,4 +1,5 @@
1
1
  import type { NavItem } from "@dogsbay/types";
2
+ import type { DogsbayConfig } from "../config/index.js";
2
3
  export interface SiteBuildOptions {
3
4
  /** Explicit path to a config file; bypasses upward search. */
4
5
  config?: string;
@@ -30,15 +31,26 @@ export interface SiteBuildOptions {
30
31
  from?: string;
31
32
  nav?: string;
32
33
  /**
33
- * Export the final page set to a second format (e.g. `obsidian`) in
34
- * addition to Astro. See plans/site-build-export-obsidian.md.
34
+ * Build targets, repeatable `--to <format>`: `astro`, `dogsbay-md` (the
35
+ * resolved Markdown), or any format the export registry can write
36
+ * (`mkdocs`, `docusaurus`, `obsidian`, `openknowledge`, `starlight`).
37
+ * When given, ONLY these are built. Absent: every `outputs:` entry in
38
+ * the config, or Astro. See plans/cli-grammar.md.
35
39
  */
36
- export?: string;
40
+ to?: string[];
41
+ /**
42
+ * Output directory for a single non-Astro `--to` target, resolved against
43
+ * the CURRENT DIRECTORY (it names where the user wants the artefact, not
44
+ * a path inside the project). Astro's output is the config's `output:`;
45
+ * several targets take their paths from `outputs:`.
46
+ */
47
+ out?: string;
37
48
  /**
38
- * Output directory for `--export`. Required when it is set. Resolved
39
- * against the CURRENT DIRECTORY, not the config dir — it names where the
40
- * user wants the artifact, not a path inside the project.
49
+ * Deprecated alias of `--to <format>` (plus `--to astro` unless
50
+ * `--no-astro`). Prints the replacement and runs it.
41
51
  */
52
+ export?: string;
53
+ /** Deprecated alias of `--out` for `--export`. */
42
54
  exportOut?: string;
43
55
  /**
44
56
  * Exporter pass-throughs. Prefixed ONLY where the bare name already means
@@ -121,16 +133,28 @@ export interface SiteBuildOptions {
121
133
  */
122
134
  deliverable?: string;
123
135
  /**
124
- * `--no-astro` switch. When `false`, stop after the build-time Minja
125
- * preprocessor pass — write the resolved-Markdown mirror to
126
- * `<siteRoot>/.dogsbay/build-content/` but skip content import and Astro
127
- * page emission. The mirror is the "preprocessed markdown" artifact.
128
- * Used by the Docker image's `preprocess` stage and as a debug aid for
129
- * inspecting resolved `{{ var }}` / `{% if %}` / `{% include %}` output.
130
- * Default (undefined / true): emit the full Astro project.
136
+ * Deprecated. `--no-astro` alone is `--to dogsbay-md` with the mirror
137
+ * (`.dogsbay/build-content/`) as its output — `dogsbay resolve` is the
138
+ * named form. With `--export` it drops the Astro target. Prints the
139
+ * replacement and runs it.
131
140
  */
132
141
  astro?: boolean;
133
142
  }
143
+ /** One thing `site build` writes: a format and, for non-Astro, a directory. */
144
+ export interface BuildTarget {
145
+ format: string;
146
+ out?: string;
147
+ }
148
+ /**
149
+ * Turn the flags — new (`--to`/`--out`), deprecated (`--export`/`--export-out`/
150
+ * `--no-astro`) — and the config's `outputs:` into one list of targets. The
151
+ * deprecated forms map onto `--to` and say so; they keep working because
152
+ * three CI repos and the Docker image use them. See plans/cli-grammar.md.
153
+ */
154
+ export declare function resolveBuildTargets(options: Pick<SiteBuildOptions, "to" | "out" | "export" | "exportOut" | "astro">, config: DogsbayConfig, configPath: string, mirrorRoot: string): {
155
+ targets: BuildTarget[];
156
+ notes: string[];
157
+ };
134
158
  export declare function siteBuild(cwd: string | undefined, options: SiteBuildOptions): Promise<void>;
135
159
  /**
136
160
  * Parse repeatable `--attribute name=value` CLI args into a
@@ -15,7 +15,7 @@
15
15
  * init` is the prerequisite.
16
16
  */
17
17
  import { swapTopicmapNavs } from "../topicmap-nav.js";
18
- import { existsSync } from "node:fs";
18
+ import { cpSync, existsSync, readdirSync, rmSync } from "node:fs";
19
19
  import { dirname, isAbsolute, join, resolve } from "node:path";
20
20
  import pc from "picocolors";
21
21
  import { emitAstroPages, emitPassthroughAstroPages, emitSiteConfig, emitConfigDerivedFiles, emitDeployArtifacts, emitAgentReadinessFiles, emitMissingTranslationStubs, emitSwitcherMap, emitTaxonomyRoutes, makeSlugExcluder, buildBlogData, emitBlogFiles, emitPluginRuntime, normalizeBasePath, basePathSegments, resolvePrefixes, } from "@dogsbay/format-astro";
@@ -27,6 +27,80 @@ import { mergeAttributes, runPreprocessor, } from "../site-build/preprocess.js";
27
27
  import { collectAssets } from "../audit/lib/collect-assets.js";
28
28
  import { validateRefs } from "../audit/lib/validate-refs.js";
29
29
  import { loadPlugins, runExtendConfig, runOnContentImported, runTransformNav, runEmitFiles, collectClientModules, collectStyles, collectClientConfigs, collectComponentWrappers, } from "../plugins/index.js";
30
+ /**
31
+ * The two targets `site build` writes itself rather than through an exporter:
32
+ * Astro is the site (config `output:`), dogsbay-md is the preprocessor's
33
+ * mirror (the resolved Markdown).
34
+ */
35
+ const NATIVE_TARGETS = new Set(["astro", "dogsbay-md"]);
36
+ /**
37
+ * Turn the flags — new (`--to`/`--out`), deprecated (`--export`/`--export-out`/
38
+ * `--no-astro`) — and the config's `outputs:` into one list of targets. The
39
+ * deprecated forms map onto `--to` and say so; they keep working because
40
+ * three CI repos and the Docker image use them. See plans/cli-grammar.md.
41
+ */
42
+ export function resolveBuildTargets(options, config, configPath, mirrorRoot) {
43
+ const notes = [];
44
+ const configDir = dirname(resolve(configPath));
45
+ const fromConfig = (config.outputs ?? []).map((o) => ({
46
+ format: o.format,
47
+ out: isAbsolute(o.path) ? o.path : resolve(configDir, o.path),
48
+ }));
49
+ let targets;
50
+ if (options.to && options.to.length > 0) {
51
+ targets = [...new Set(options.to)].map((format) => ({
52
+ format,
53
+ out: fromConfig.find((o) => o.format === format)?.out,
54
+ }));
55
+ const needOut = targets.filter((t) => t.format !== "astro");
56
+ if (options.out) {
57
+ if (needOut.length !== 1) {
58
+ throw new Error(`--out names one output directory, but ` +
59
+ (needOut.length === 0
60
+ ? "no non-Astro target was given (Astro's output is `output:` in the config)"
61
+ : `${needOut.length} non-Astro targets were given`) +
62
+ `. To fix: declare the paths under \`outputs:\` in dogsbay.config.yml, ` +
63
+ `or build one target per invocation.`);
64
+ }
65
+ needOut[0].out = resolve(options.out);
66
+ }
67
+ for (const t of needOut) {
68
+ if (!t.out) {
69
+ throw new Error(`--to ${t.format} needs an output directory. To fix: add --out <dir>, ` +
70
+ `or declare it under \`outputs:\` in dogsbay.config.yml.`);
71
+ }
72
+ }
73
+ if (options.export || options.astro === false) {
74
+ notes.push("--to given: the deprecated --export / --no-astro flags are ignored.");
75
+ }
76
+ }
77
+ else if (options.export) {
78
+ if (!options.exportOut) {
79
+ throw new Error(`--export ${options.export} needs an output directory. To fix: use ` +
80
+ `--to ${options.export} --out <dir> (--export/--export-out are deprecated).`);
81
+ }
82
+ targets = [{ format: options.export, out: resolve(options.exportOut) }];
83
+ if (options.astro !== false)
84
+ targets.unshift({ format: "astro" });
85
+ notes.push(`--export/--export-out are deprecated: use ` +
86
+ (options.astro === false
87
+ ? `--to ${options.export} --out ${options.exportOut}`
88
+ : `--to astro --to ${options.export} --out ${options.exportOut}`));
89
+ }
90
+ else if (options.astro === false) {
91
+ targets = [{ format: "dogsbay-md", out: mirrorRoot }];
92
+ notes.push("--no-astro is deprecated: use `dogsbay resolve <dir> -o <out>` " +
93
+ "(or `site build --to dogsbay-md --out <dir>`). The mirror stays at " +
94
+ ".dogsbay/build-content/ for this run.");
95
+ }
96
+ else if (fromConfig.length > 0) {
97
+ targets = fromConfig;
98
+ }
99
+ else {
100
+ targets = [{ format: "astro" }];
101
+ }
102
+ return { targets, notes };
103
+ }
30
104
  export async function siteBuild(cwd, options) {
31
105
  const startDir = resolve(cwd || ".");
32
106
  // 1. Find the config file
@@ -62,30 +136,47 @@ export async function siteBuild(cwd, options) {
62
136
  }
63
137
  // 3. CLI overrides
64
138
  config = mergeOverrides(config, options);
65
- // 4. Resolve where the generated Astro project lives. Default
66
- // `./astro` puts the project in a subfolder; `output: "."` keeps
67
- // everything flat at the project root.
68
- // --export: validate the target BEFORE resolving sources, preprocessing and
69
- // importing. `convert` learned this the expensive way (convert.ts:582) —
70
- // failing late means a typo costs a full run over a 20k-page corpus. The
71
- // throw inside runSecondaryExport stays as a cheap invariant.
72
- if (options.export) {
73
- if (!options.exportOut) {
74
- throw new Error(`--export ${options.export} needs an output directory. To fix: add ` +
75
- `--export-out <dir> (e.g. --export ${options.export} --export-out ../my-vault).`);
76
- }
139
+ // 4. What this build writes. Targets are settled — and every exporter
140
+ // named is resolved against the registry — BEFORE sources are fetched,
141
+ // preprocessed and imported: `convert` learned this the expensive way
142
+ // (convert.ts:582), a typo that fails late costs a full run over a
143
+ // 20k-page corpus. The mirror root is needed here because the deprecated
144
+ // `--no-astro` is `--to dogsbay-md` with the mirror as its output.
145
+ const mirrorRoot = options.deliverable
146
+ ? join(siteRoot, ".dogsbay", "deliverables", options.deliverable, "build-content")
147
+ : join(siteRoot, ".dogsbay", "build-content");
148
+ const { targets, notes: targetNotes } = resolveBuildTargets(options, config, configPath, mirrorRoot);
149
+ for (const n of targetNotes)
150
+ console.warn(pc.yellow(` Note: ${n}`));
151
+ const buildsAstro = targets.some((t) => t.format === "astro");
152
+ const mirrorTarget = targets.find((t) => t.format === "dogsbay-md");
153
+ const exportTargets = targets.filter((t) => !NATIVE_TARGETS.has(t.format));
154
+ if (exportTargets.length > 0) {
77
155
  const { resolveExporter } = await import("../formats.js");
78
- resolveExporter(options.export);
156
+ for (const t of exportTargets)
157
+ resolveExporter(t.format);
158
+ }
159
+ if (options.preprocess === false) {
160
+ if (mirrorTarget) {
161
+ throw new Error("--to dogsbay-md is the RESOLVED Markdown, and --no-preprocess skips " +
162
+ "resolution. To fix: drop one of the two flags.");
163
+ }
79
164
  // The export path relies on the preprocessor having spliced
80
165
  // `{% include %}`: unresolved include nodes serialize to nothing, so the
81
166
  // transclusions vanish from the output with a clean exit. (`convert` runs
82
167
  // realizePageIncludes for exactly this reason.)
83
- if (options.preprocess === false) {
84
- console.warn(pc.yellow(` Warning: --no-preprocess with --export drops {% include %} ` +
85
- `content — transclusions are resolved by the preprocessor and ` +
86
- `the export cannot recover them.`));
168
+ if (exportTargets.length > 0) {
169
+ console.warn(pc.yellow(` Warning: --no-preprocess with --to ${exportTargets.map((t) => t.format).join("/")} ` +
170
+ `drops {% include %} content — transclusions are resolved by the ` +
171
+ `preprocessor and the export cannot recover them.`));
87
172
  }
88
173
  }
174
+ if (targets.length > 1 || !buildsAstro) {
175
+ console.log(` Targets: ${targets.map((t) => (t.out && t.format !== "astro" ? `${t.format} → ${t.out}` : t.format)).join(", ")}`);
176
+ }
177
+ // 4a. Where the generated Astro project lives. Default `./astro` puts the
178
+ // project in a subfolder; `output: "."` keeps everything flat at the
179
+ // project root.
89
180
  const outputDir = resolveOutputDir(config, configPath);
90
181
  // 4b. Load plugins (resolve npm names, factory invocations,
91
182
  // topo-sort). Plugin loading is fail-fast: a misconfigured
@@ -115,7 +206,7 @@ export async function siteBuild(cwd, options) {
115
206
  // Refuse to build without a scaffold — init is the prerequisite.
116
207
  // Mirror-only builds (--no-astro) never touch the scaffold, so they
117
208
  // run without one (e.g. per-deliverable mirror builds).
118
- if (options.astro !== false && !existsSync(`${outputDir}/package.json`)) {
209
+ if (buildsAstro && !existsSync(`${outputDir}/package.json`)) {
119
210
  console.error(pc.red(`Error: no scaffold found at ${outputDir}.`));
120
211
  console.error(` Run \`dogsbay site init ${siteRoot}\` first.`);
121
212
  process.exit(1);
@@ -214,9 +305,6 @@ export async function siteBuild(cwd, options) {
214
305
  resolvedPath: resolvedSources[i],
215
306
  attributes: mergeAttributes(config.attributes, src.attributes, cliAttributes),
216
307
  }));
217
- const mirrorRoot = options.deliverable
218
- ? join(siteRoot, ".dogsbay", "deliverables", options.deliverable, "build-content")
219
- : join(siteRoot, ".dogsbay", "build-content");
220
308
  const result = await runPreprocessor(preprocessSources, {
221
309
  mirrorRoot,
222
310
  undefinedRefs: config.undefinedRefs,
@@ -240,16 +328,28 @@ export async function siteBuild(cwd, options) {
240
328
  console.log(` Preprocessor: ${changes.join(", ")} → ${mirrorRoot}`);
241
329
  }
242
330
  }
243
- // --no-astro: stop after the preprocessor. The resolved-Minja mirror under
244
- // <siteRoot>/.dogsbay/build-content/ IS the artifact ("preprocessed
245
- // markdown"); skip content import + Astro emission. Powers the Docker
246
- // `preprocess` stage and serves as a debug aid.
247
- // `--export` needs the imported page set, so it overrides this stop:
248
- // with both flags, --no-astro means "emit only the export target" and
249
- // the build returns after 7e instead.
250
- if (options.astro === false && !options.export) {
251
- console.log(" --no-astro: stopped after preprocess (resolved MD in .dogsbay/build-content/)");
252
- return;
331
+ // 6b. --to dogsbay-md: the resolved Markdown IS the artefact. It is the
332
+ // preprocessor's mirror — every file, fragments included, with `{{ var }}`
333
+ // substituted, `{% if %}` chosen and `{% include %}` spliced, directives
334
+ // untouched. Copied to the requested directory (the deprecated --no-astro
335
+ // leaves it in the mirror). This is what `dogsbay resolve` produces, and
336
+ // what the Docker image's `preprocess` stage and RAG ingestion consume.
337
+ if (mirrorTarget) {
338
+ if (importPaths.length > 1) {
339
+ throw new Error(`--to dogsbay-md cannot resolve a multi-source site (${importPaths.length} ` +
340
+ `sources) into one directory. To fix: resolve one source at a time ` +
341
+ `with --deliverable <name>.`);
342
+ }
343
+ const out = mirrorTarget.out;
344
+ if (out !== mirrorRoot) {
345
+ guardOutputDir(out, siteRoot, resolvedSources);
346
+ rmSync(out, { recursive: true, force: true });
347
+ cpSync(importPaths[0], out, { recursive: true });
348
+ }
349
+ const n = countFiles(out, ".md");
350
+ console.log(` Resolved ${n} Markdown file(s) → ${out}`);
351
+ if (!buildsAstro && exportTargets.length === 0)
352
+ return;
253
353
  }
254
354
  // 7. Import content
255
355
  //
@@ -387,13 +487,15 @@ export async function siteBuild(cwd, options) {
387
487
  : ""));
388
488
  }
389
489
  }
390
- // 7e. --export <format>: hand the FINAL page set to a second exporter.
391
- // A vault needs exactly the pages the site gets — attributes resolved,
392
- // variants merged, topic-map navs swapped, drafts dropped, routablesFrom
393
- // applied. None of that is recoverable from a content directory, which is
394
- // why this lives here and not in `convert`. After 7d so a --strict failure
395
- // blocks both outputs rather than shipping a vault the site rejected.
396
- if (options.export) {
490
+ // 7e. --to <format>: hand the FINAL page set to each exporter. A vault or
491
+ // an MkDocs project needs exactly the pages the site gets — attributes
492
+ // resolved, variants merged, topic-map navs swapped, drafts dropped,
493
+ // routablesFrom applied. None of that is recoverable from a content
494
+ // directory, which is why this lives here and not in `convert`. After 7d
495
+ // so a --strict failure blocks every output rather than shipping one the
496
+ // site rejected. One pipeline pass, N serializations: that is what makes
497
+ // the OpenShift Astro/MkDocs/Docusaurus sites page-for-page comparable.
498
+ if (exportTargets.length > 0) {
397
499
  // Multi-source builds rewrite each source's asset refs under its own
398
500
  // version/locale prefix (import-content.ts), but importer mounts carry no
399
501
  // matching `into`, so a vault would reference `4.22/_assets/x` while the
@@ -401,23 +503,25 @@ export async function siteBuild(cwd, options) {
401
503
  // handles this with assetSourceDirs; the export path has no equivalent,
402
504
  // so refuse rather than ship it silently.
403
505
  if (resolvedSources.length > 1) {
404
- throw new Error(`--export cannot build a multi-source site (${resolvedSources.length} ` +
506
+ throw new Error(`--to ${exportTargets[0].format} cannot build a multi-source site (${resolvedSources.length} ` +
405
507
  `sources): per-source asset prefixes would not resolve in the ` +
406
508
  `output. To fix: export one source at a time with ` +
407
509
  `--deliverable <name>, or drop the extra sources for this run.`);
408
510
  }
409
- await runSecondaryExport(pages, nav, {
410
- format: options.export,
411
- out: options.exportOut,
412
- siteName: config.site.name,
413
- siteUrl: config.site.url,
414
- stripPrefix: combinedPrefix,
415
- sourceDir: resolvedSources[0],
416
- assetMounts: importResult.assetMounts,
417
- plugin: options.exportPlugin,
418
- graphGroups: options.graphGroups,
419
- });
420
- if (options.astro === false)
511
+ for (const t of exportTargets) {
512
+ await runSecondaryExport(pages, nav, {
513
+ format: t.format,
514
+ out: t.out,
515
+ siteName: config.site.name,
516
+ siteUrl: config.site.url,
517
+ stripPrefix: combinedPrefix,
518
+ sourceDir: resolvedSources[0],
519
+ assetMounts: importResult.assetMounts,
520
+ plugin: options.exportPlugin,
521
+ graphGroups: options.graphGroups,
522
+ });
523
+ }
524
+ if (!buildsAstro)
421
525
  return;
422
526
  }
423
527
  // 7. Emit content / config-derived / agent-readiness tiers.
@@ -629,7 +733,12 @@ export function parseAttributePairs(pairs) {
629
733
  const value = raw.slice(eq + 1).trim();
630
734
  if (!name)
631
735
  continue;
632
- out[name] = value;
736
+ // `-a enterprise=false` must mean false. Everything from the shell is a
737
+ // string, and the string "false" is truthy to `{% if enterprise %}` —
738
+ // which selected the ENTERPRISE branch for the flag that said not to.
739
+ // Only the two literals are coerced; a YAML-side `attributes:` block
740
+ // already types them.
741
+ out[name] = value === "true" ? true : value === "false" ? false : value;
633
742
  }
634
743
  return Object.keys(out).length > 0 ? out : undefined;
635
744
  }
@@ -949,6 +1058,36 @@ export function stripNavPrefix(nav, prefix) {
949
1058
  * same registry `convert` uses, so any format that can export is reachable
950
1059
  * here without further wiring.
951
1060
  */
1061
+ /**
1062
+ * A directory that is about to be wiped and rewritten must not be the site,
1063
+ * a source, or an ancestor of either. The mirror copy is `rm -rf` + copy so
1064
+ * that deleted pages do not linger (the criterion is "identical to the
1065
+ * mirror"), and that is exactly the operation that must never point at the
1066
+ * wrong place.
1067
+ */
1068
+ function guardOutputDir(out, siteRoot, sources) {
1069
+ const inside = (parent, child) => child === parent || child.startsWith(parent.endsWith("/") ? parent : parent + "/");
1070
+ for (const protectedDir of [siteRoot, ...sources]) {
1071
+ if (inside(out, protectedDir)) {
1072
+ throw new Error(`refusing to write the resolved Markdown to ${out}: it contains ${protectedDir}. ` +
1073
+ `To fix: choose an output directory outside the site and its sources.`);
1074
+ }
1075
+ }
1076
+ }
1077
+ function countFiles(dir, ext) {
1078
+ let n = 0;
1079
+ const walk = (d) => {
1080
+ for (const e of readdirSync(d, { withFileTypes: true })) {
1081
+ if (e.isDirectory())
1082
+ walk(join(d, e.name));
1083
+ else if (e.isFile() && e.name.endsWith(ext))
1084
+ n++;
1085
+ }
1086
+ };
1087
+ if (existsSync(dir))
1088
+ walk(dir);
1089
+ return n;
1090
+ }
952
1091
  async function runSecondaryExport(pages, nav, opts) {
953
1092
  const { resolveExporter } = await import("../formats.js");
954
1093
  const exporter = resolveExporter(opts.format);
@@ -167,6 +167,7 @@ function validate(parsed, sourcePath) {
167
167
  toc: obj.toc,
168
168
  content,
169
169
  output: obj.output,
170
+ outputs: parseOutputs(obj.outputs, sourcePath),
170
171
  deploy: obj.deploy,
171
172
  analytics: obj.analytics,
172
173
  agent,
@@ -1091,3 +1092,27 @@ function validateBlog(raw, sourcePath) {
1091
1092
  : {}),
1092
1093
  };
1093
1094
  }
1095
+ /**
1096
+ * `outputs:` — a list of `{ format, path }`. Shape only: whether a format is
1097
+ * buildable is decided by `site build` against the export registry, so this
1098
+ * loader does not import the formats (they would pull every importer in).
1099
+ */
1100
+ function parseOutputs(raw, sourcePath) {
1101
+ if (raw === undefined)
1102
+ return undefined;
1103
+ if (!Array.isArray(raw)) {
1104
+ throw new Error(`outputs must be a list of { format, path } in ${sourcePath}`);
1105
+ }
1106
+ const seen = new Set();
1107
+ return raw.map((entry, i) => {
1108
+ const e = entry;
1109
+ if (!e || typeof e !== "object" || typeof e.format !== "string" || typeof e.path !== "string") {
1110
+ throw new Error(`outputs[${i}] must be { format: <string>, path: <string> } in ${sourcePath}`);
1111
+ }
1112
+ if (seen.has(e.format)) {
1113
+ throw new Error(`outputs lists "${e.format}" twice in ${sourcePath} — one directory per format`);
1114
+ }
1115
+ seen.add(e.format);
1116
+ return { format: e.format, path: e.path };
1117
+ });
1118
+ }
@@ -89,6 +89,11 @@ export interface DeliverableConfig {
89
89
  /** Per-deliverable agent/KB outputs, merged over the site `agent:`. */
90
90
  agent?: AgentConfig;
91
91
  }
92
+ /** One entry of `outputs:` — a target format and where it is written. */
93
+ export interface OutputTarget {
94
+ format: string;
95
+ path: string;
96
+ }
92
97
  export interface DogsbayConfig {
93
98
  /** Schema version. Must be 1 for now. */
94
99
  schemaVersion: 1;
@@ -109,6 +114,16 @@ export interface DogsbayConfig {
109
114
  * config dir. See docs/standard-layout.md.
110
115
  */
111
116
  output?: string;
117
+ /**
118
+ * Every generator this site publishes to, from ONE pipeline pass. Each
119
+ * entry is a format from the export registry (`mkdocs`, `docusaurus`,
120
+ * `obsidian`, …), `astro`, or `dogsbay-md` (the resolved Markdown), with
121
+ * the directory it is written to, relative to the config file. `site
122
+ * build` with no `--to` builds every entry; `--to` narrows to the named
123
+ * ones. Absent: `[{ format: "astro", path: output }]`.
124
+ * See plans/cli-grammar.md.
125
+ */
126
+ outputs?: OutputTarget[];
112
127
  deploy?: DeployConfig;
113
128
  /**
114
129
  * Build / asset behaviour. Knobs that affect how the site
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { dirname, join } from "node:path";
5
5
  import { Command } from "commander";
6
+ import pc from "picocolors";
6
7
  import { init } from "./commands/init.js";
7
8
  import { VALID_FROM } from "./config/load.js";
8
9
  import { add, list } from "./commands/add.js";
@@ -131,12 +132,18 @@ site
131
132
  .option("--content <path>", "Override content.source")
132
133
  .option("--from <format>", "Override content.from")
133
134
  .option("--nav <path>", "Override content.nav")
134
- .option("--export <format>", "Also export the built page set to another format (e.g. obsidian). " +
135
- "Uses the fully resolved pages the site gets — attributes, variants, " +
136
- "drafts and routablesFrom applied. Requires -o")
137
- .option("--export-out <dir>", "Output directory for --export, relative to the current directory")
138
- .option("--export-plugin <mode>", "--export obsidian: vault plugin setup, bundled | registry | none")
139
- .option("--graph-groups <path>", "--export obsidian: graph-view colour groups, JSON. Ignored if the vault already has a graph.json")
135
+ .option("--to <format>", "Build target (repeatable): astro (the default) | mkdocs | docusaurus | " +
136
+ "obsidian | openknowledge | starlight | dogsbay-md (the resolved Markdown). " +
137
+ "Every target is serialized from the SAME resolved page set — attributes, " +
138
+ "variants, drafts and routablesFrom applied. With --to, only the named " +
139
+ "targets are built; without it, every `outputs:` entry in the config, or astro.", (val, prev = []) => [...prev, val])
140
+ .option("--out <dir>", "Output directory for a single non-Astro --to target, relative to the " +
141
+ "current directory. Astro's output is the config's `output:`; several " +
142
+ "targets take their paths from `outputs:`.")
143
+ .option("--export <format>", "Deprecated — use --to <format> (with --to astro to keep the site)")
144
+ .option("--export-out <dir>", "Deprecated — use --out <dir>")
145
+ .option("--export-plugin <mode>", "--to obsidian: vault plugin setup, bundled | registry | none")
146
+ .option("--graph-groups <path>", "--to obsidian: graph-view colour groups, JSON. Ignored if the vault already has a graph.json")
140
147
  .option("--plausible-domain <domain>", "Override analytics.plausible.domain")
141
148
  .option("--plausible-script-url <url>", "Override analytics.plausible.scriptUrl")
142
149
  .option("--no-llms-txt", "Disable llms.txt for this invocation only")
@@ -164,27 +171,26 @@ site
164
171
  .option("--no-preprocess", "Skip the build-time Minja preprocessor pass even when " +
165
172
  "`attributes:` are declared. Debug aid — shows the raw " +
166
173
  "`{{ var }}` directives the importer left unresolved.")
167
- .option("--no-astro", "Stop after the Minja preprocessor — write the resolved-Markdown " +
168
- "mirror to .dogsbay/build-content/ but skip content import + Astro " +
169
- "page emission. The mirror is the 'preprocessed markdown' artifact. " +
170
- "With --export this instead means 'build only the export target', " +
171
- "which still runs the import.")
174
+ .option("--no-astro", "Deprecated — the resolved Markdown is `dogsbay resolve` (or " +
175
+ "--to dogsbay-md --out <dir>); a single other target is --to <format>.")
172
176
  .option("--deliverable <name>", "Build under a named deliverable from the config's `deliverables:` " +
173
177
  "section — its attributes/nav/out/agent apply on top of the base " +
174
178
  "config. See plans/deliverables.md.")
175
179
  .option("--all-deliverables", "Build every deliverable in sequence. Deliverables without their own " +
176
180
  "`out:` share the site scaffold, so this is most useful with " +
177
- "--no-astro (per-deliverable resolved-markdown mirrors under " +
178
- ".dogsbay/deliverables/<name>/) or with distinct out: dirs.")
181
+ "`dogsbay resolve --deliverable` / --to dogsbay-md (per-deliverable " +
182
+ "resolved-markdown mirrors under .dogsbay/deliverables/<name>/) or with " +
183
+ "distinct out: dirs.")
179
184
  .action(async (dir, options) => {
180
185
  try {
181
186
  if (options.allDeliverables) {
182
- // Every deliverable would export into the SAME --export-out dir and
183
- // the last one would silently win. One export target per invocation.
184
- if (options.export) {
185
- throw new Error("--export builds one target per invocation, so " +
187
+ // Every deliverable would write into the SAME --out dir and the last
188
+ // one would silently win. One non-Astro target per invocation.
189
+ const nonAstro = (options.to ?? []).filter((t) => t !== "astro");
190
+ if (options.export || options.out || nonAstro.length > 0) {
191
+ throw new Error("--to <format> builds one output directory per invocation, so " +
186
192
  "--all-deliverables would overwrite it once per deliverable. " +
187
- "To fix: run --export once per deliverable with --deliverable <name>.");
193
+ "To fix: run it once per deliverable with --deliverable <name>.");
188
194
  }
189
195
  const { findConfig: find, loadConfig: load, listDeliverables: list } = await import("./config/index.js");
190
196
  const { resolve: resolvePath, dirname: dirnameOf } = await import("node:path");
@@ -489,9 +495,42 @@ program
489
495
  .option("--output <file>", "Output file path (default: lighthouse-results.txt)")
490
496
  .action((url, options) => lighthouse(url, options));
491
497
  program
492
- .command("convert")
493
- .description("Convert content between formats (MkDocs / Dogsbay-MD / Obsidian / etc.). " +
494
- "Pure content primitive — for full SSG sites use `dogsbay site init && site build`.")
498
+ .command("resolve")
499
+ .description("Resolve Dogsbay MD: substitute {{ variables }}, choose {% if %} branches and " +
500
+ "splice {% include %}s, writing plain Dogsbay MD with the directives intact. " +
501
+ "Reads dogsbay.config.yml when the directory has one (sources, attributes, " +
502
+ "deliverables); otherwise resolves the directory as given with the -a attributes. " +
503
+ "The ingestion form for an LLM wiki or a RAG corpus, and the same tree " +
504
+ "`site build` serializes to every target.")
505
+ .argument("[dir]", "Site root (with dogsbay.config.yml) or a directory of Dogsbay MD (default: cwd)")
506
+ .requiredOption("-o, --out <dir>", "Output directory, relative to the current directory. Replaced wholesale")
507
+ .option("--config <path>", "Explicit path to dogsbay.config.{yml,yaml,json}")
508
+ .option("-a, --attribute <name=value>", "Attribute value (repeatable). With a config, stacks onto its `attributes:`; " +
509
+ "without one, the whole context", (v, prev = []) => [...prev, v])
510
+ .option("--deliverable <name>", "Resolve under a named deliverable (config only)")
511
+ .option("--undefined-refs <mode>", "Directory mode only: `preserve` keeps `{{ x }}` for an undefined x (default); " +
512
+ "`asciidoc-literal` writes `{x}` (AsciiDoc-migrated corpora)")
513
+ .action(async (dir, options) => {
514
+ try {
515
+ const { resolveCommand } = await import("./commands/resolve.js");
516
+ await resolveCommand(dir, options);
517
+ }
518
+ catch (err) {
519
+ cliFail(err);
520
+ }
521
+ });
522
+ // Hidden from the first help screen, not removed: `convert` is the page-level
523
+ // primitive with no config, no attributes and no variant, which is exactly
524
+ // why its `--to astro` form emitted half a project with `{{ product_title }}`
525
+ // visible on every page and exit 0. The jobs people have are `import` (a
526
+ // project that builds), `site build --to <format>` (publish it) and `resolve`
527
+ // (a snapshot). It stays for scripts and the conformance suite, and prints
528
+ // where it stands. See plans/cli-grammar.md.
529
+ program
530
+ .command("convert", { hidden: true })
531
+ .description("Page-level format conversion with no site pipeline (no config, attributes, " +
532
+ "variants or nav selection). Prefer `site build --to <format>` on a Dogsbay " +
533
+ "project, or `dogsbay resolve` for resolved Markdown.")
495
534
  .argument("<source>", "Path to source project")
496
535
  .option("-f, --from <format>", "Source format (auto-detected if omitted)")
497
536
  .option("-t, --to <format>", "Target format (required)")
@@ -536,7 +575,13 @@ program
536
575
  .option("--eval-config", "VitePress: EXECUTE .vitepress/config.* instead of reading it statically. Runs code from the source repo and needs its dependencies installed — use only on a project you trust, and only when the sidebar is genuinely computed")
537
576
  .option("--distro <name>", "AsciiBinder distro filter (--from asciidoc), e.g. openshift-enterprise")
538
577
  .option("--topic-map <path>", "AsciiBinder topic-map path relative to the source root (--from asciidoc; default: _topic_maps/_topic_map.yml)")
539
- .action((source, options) => convert(source, options));
578
+ .action((source, options) => {
579
+ console.warn(pc.yellow(" Note: `convert` runs no site pipeline — attributes, variants and nav " +
580
+ "selection do not apply. On a Dogsbay project use `site build --to " +
581
+ (options.to ?? "<format>") +
582
+ "`; for resolved Markdown use `dogsbay resolve`."));
583
+ return convert(source, options);
584
+ });
540
585
  program
541
586
  .command("import-dita")
542
587
  .description("Convert DITA topic content (.dita file or directory) to dogsbay-md, with a conversion report")
@@ -605,4 +650,6 @@ program
605
650
  // topicmap-resolve, reindex — commands + flags generated from the
606
651
  // registry's zod schemas (see engine-cli.ts)
607
652
  registerEngineCommands(program);
653
+ program.addHelpText("after", "\nLower-level: `dogsbay convert <src> --to <format>` converts a directory with no " +
654
+ "site pipeline (run `dogsbay convert --help`).\n");
608
655
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dogsbay",
3
- "version": "0.2.0-beta.110",
3
+ "version": "0.2.0-beta.112",
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": {
@@ -42,32 +42,32 @@
42
42
  "typescript": "^5.9.3",
43
43
  "valibot": "1.1.0",
44
44
  "yaml": "^2.9.0",
45
- "@dogsbay/adoc2md-modular": "0.2.0-beta.110",
46
- "@dogsbay/autodoc-python": "0.2.0-beta.110",
47
- "@dogsbay/commands": "0.2.0-beta.110",
48
- "@dogsbay/format-asciidoc": "0.2.0-beta.110",
49
- "@dogsbay/format-astro": "0.2.0-beta.110",
50
- "@dogsbay/format-dita": "0.2.0-beta.110",
51
- "@dogsbay/format-docusaurus": "0.2.0-beta.110",
52
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.110",
53
- "@dogsbay/format-fluidtopics-markdown": "0.2.0-beta.110",
54
- "@dogsbay/format-fluidtopics-rest": "0.2.0-beta.110",
55
- "@dogsbay/format-gatsby": "0.2.0-beta.110",
56
- "@dogsbay/format-gitbook": "0.2.0-beta.110",
57
- "@dogsbay/format-hugo": "0.2.0-beta.110",
58
- "@dogsbay/format-mdx": "0.2.0-beta.110",
59
- "@dogsbay/format-mkdocs": "0.2.0-beta.110",
60
- "@dogsbay/format-myst": "0.2.0-beta.110",
61
- "@dogsbay/format-obsidian": "0.2.0-beta.110",
62
- "@dogsbay/format-openapi": "0.2.0-beta.110",
63
- "@dogsbay/format-openknowledge": "0.2.0-beta.110",
64
- "@dogsbay/format-rst": "0.2.0-beta.110",
65
- "@dogsbay/format-starlight": "0.2.0-beta.110",
66
- "@dogsbay/format-topicmap": "0.2.0-beta.110",
67
- "@dogsbay/format-vitepress": "0.2.0-beta.110",
68
- "@dogsbay/minja": "0.2.0-beta.110",
69
- "@dogsbay/serialize-core": "0.2.0-beta.110",
70
- "@dogsbay/types": "0.2.0-beta.110"
45
+ "@dogsbay/adoc2md-modular": "0.2.0-beta.112",
46
+ "@dogsbay/autodoc-python": "0.2.0-beta.112",
47
+ "@dogsbay/format-asciidoc": "0.2.0-beta.112",
48
+ "@dogsbay/format-astro": "0.2.0-beta.112",
49
+ "@dogsbay/format-dita": "0.2.0-beta.112",
50
+ "@dogsbay/commands": "0.2.0-beta.112",
51
+ "@dogsbay/format-docusaurus": "0.2.0-beta.112",
52
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.112",
53
+ "@dogsbay/format-fluidtopics-markdown": "0.2.0-beta.112",
54
+ "@dogsbay/format-gatsby": "0.2.0-beta.112",
55
+ "@dogsbay/format-gitbook": "0.2.0-beta.112",
56
+ "@dogsbay/format-hugo": "0.2.0-beta.112",
57
+ "@dogsbay/format-fluidtopics-rest": "0.2.0-beta.112",
58
+ "@dogsbay/format-mdx": "0.2.0-beta.112",
59
+ "@dogsbay/format-mkdocs": "0.2.0-beta.112",
60
+ "@dogsbay/format-myst": "0.2.0-beta.112",
61
+ "@dogsbay/format-obsidian": "0.2.0-beta.112",
62
+ "@dogsbay/format-openapi": "0.2.0-beta.112",
63
+ "@dogsbay/format-rst": "0.2.0-beta.112",
64
+ "@dogsbay/format-starlight": "0.2.0-beta.112",
65
+ "@dogsbay/format-openknowledge": "0.2.0-beta.112",
66
+ "@dogsbay/format-topicmap": "0.2.0-beta.112",
67
+ "@dogsbay/minja": "0.2.0-beta.112",
68
+ "@dogsbay/format-vitepress": "0.2.0-beta.112",
69
+ "@dogsbay/serialize-core": "0.2.0-beta.112",
70
+ "@dogsbay/types": "0.2.0-beta.112"
71
71
  },
72
72
  "devDependencies": {
73
73
  "@types/markdown-it": "^14.1.2",