@dogsbay/ssg 0.2.0-beta.144 → 0.2.0-beta.146

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.
@@ -1,31 +1,31 @@
1
1
  Third-party software in @dogsbay/ssg (dist/package/)
2
2
 
3
3
  ========================================================================
4
- @dogsbay/elements@0.2.0-beta.144 (MIT)
4
+ @dogsbay/elements@0.2.0-beta.146 (MIT)
5
5
  https://github.com/dogsbay/dogsbay.git
6
6
 
7
7
  Licensed under MIT; the package ships no licence file.
8
8
 
9
9
  ========================================================================
10
- @dogsbay/primitives@0.2.0-beta.144 (MIT)
10
+ @dogsbay/primitives@0.2.0-beta.146 (MIT)
11
11
  https://github.com/dogsbay/dogsbay.git
12
12
 
13
13
  Licensed under MIT; the package ships no licence file.
14
14
 
15
15
  ========================================================================
16
- @dogsbay/render-html@0.2.0-beta.144 (MIT)
16
+ @dogsbay/render-html@0.2.0-beta.146 (MIT)
17
17
  https://github.com/dogsbay/dogsbay.git
18
18
 
19
19
  Licensed under MIT; the package ships no licence file.
20
20
 
21
21
  ========================================================================
22
- @dogsbay/search@0.2.0-beta.144 (MIT)
22
+ @dogsbay/search@0.2.0-beta.146 (MIT)
23
23
  https://github.com/dogsbay/dogsbay.git
24
24
 
25
25
  Licensed under MIT; the package ships no licence file.
26
26
 
27
27
  ========================================================================
28
- @dogsbay/ui@0.2.0-beta.144 (MIT)
28
+ @dogsbay/ui@0.2.0-beta.146 (MIT)
29
29
  https://github.com/dogsbay/dogsbay.git
30
30
 
31
31
  Licensed under MIT; the package ships no licence file.
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  // src/core/build.ts
20
20
  import { dogsbayMdToTree, splitFrontmatter as splitFrontmatter2, treeToDogsbayMd } from "@dogsbay/format-dogsbay-md/browser";
21
+ import { mergeHeaders, mergeRedirects, productRootRedirects, siteFiles, siteRootTarget, toScript, unitOf } from "@dogsbay/site-files";
21
22
  import { buildHreflangLinks, isMountRoot, missingTranslations, mountRefs, mountRootNote, namespaceNavGroup, namespacePrefixes, placeMountedNav, prefixSlug as prefixSlug2, rewriteContentRefs } from "@dogsbay/site-files";
22
23
 
23
24
  // src/core/canonical.ts
@@ -26,7 +27,7 @@ import { declaresParams, splitFrontmatter } from "@dogsbay/format-dogsbay-md/bro
26
27
 
27
28
  // src/core/config.ts
28
29
  import { scriptUrl } from "@dogsbay/render-html";
29
- import { assertLeafSitemap, checkVersionSeo, isNamespaceAxisActive, namespaceSegments, parseMount } from "@dogsbay/site-files";
30
+ import { assertLeafSitemap, checkVersionSeo, isNamespaceAxisActive, namespaceSegments, parseMount, ProductConfigError, productModel, productOf, sourceNamespace, sourceUnit, unitSegments } from "@dogsbay/site-files";
30
31
 
31
32
  // src/core/yaml.ts
32
33
  import { load } from "js-yaml";
@@ -36,6 +37,17 @@ function loadYaml(text) {
36
37
  }
37
38
 
38
39
  // src/core/config.ts
40
+ function shared(f) {
41
+ try {
42
+ return f();
43
+ } catch (e) {
44
+ if (e instanceof ProductConfigError) throw new ConfigError(e.message);
45
+ throw e;
46
+ }
47
+ }
48
+ function versionsOf(cfg, product) {
49
+ return cfg.productVersions?.get(product ?? "") ?? { versions: cfg.versions, defaultVersion: cfg.defaultVersion };
50
+ }
39
51
  var ConfigError = class extends Error {
40
52
  };
41
53
  function sourceTemplates(v, path) {
@@ -67,10 +79,10 @@ function unitId(kind, v) {
67
79
  if (!/^[a-z0-9][a-z0-9._-]*$/i.test(id)) throw new ConfigError(`${kind}: id "${id.slice(0, 80)}" must be one URL segment of letters, digits, dots, dashes and underscores`);
68
80
  return id;
69
81
  }
70
- function sourceMount(s) {
82
+ function sourceMount(s, products) {
71
83
  const at = `content.sources[${String(s.path)}]`;
72
84
  if (s.from !== "openapi") throw new ConfigError(`${at}: mount is only for a "from: openapi" source`);
73
- if (s.name != null) throw new ConfigError(`${at} has both name and mount: a mounted source goes inside its version, so it takes no name`);
85
+ if (s.name != null && !products) throw new ConfigError(`${at} has both name and mount: a mounted source goes inside its version, so it takes no name (with content.products:, it names its product)`);
74
86
  const parsed = parseMount(s.mount);
75
87
  if ("error" in parsed) throw new ConfigError(`${at}: ${parsed.error}`);
76
88
  return parsed.segments.join("/");
@@ -140,6 +152,7 @@ function parseConfig(text, overrides = {}) {
140
152
  if (navMode !== void 0 && navMode !== "client" && navMode !== "ssr-full") throw new ConfigError(`build.navMode must be "client" or "ssr-full"; got ${JSON.stringify(navMode)}`);
141
153
  const site = raw.site ?? {};
142
154
  const content = raw.content ?? {};
155
+ const productsBlock = content.products != null;
143
156
  const ssg = raw.ssg ?? {};
144
157
  const agent = raw.agent ?? {};
145
158
  const signal = agent.contentSignal ?? {};
@@ -147,7 +160,7 @@ function parseConfig(text, overrides = {}) {
147
160
  path: sourcePath(s),
148
161
  ...s.name != null ? { name: sourceName(s.name) } : {},
149
162
  ...s.from != null ? { from: sourceFrom(s.from, String(s.path)) } : {},
150
- ...s.mount != null ? { mount: sourceMount(s) } : {},
163
+ ...s.mount != null ? { mount: sourceMount(s, productsBlock) } : {},
151
164
  version: s.version != null ? unitId("version", s.version) : void 0,
152
165
  locale: s.locale != null ? unitId("locale", s.locale) : void 0,
153
166
  attributes: s.attributes ?? void 0,
@@ -164,7 +177,7 @@ function parseConfig(text, overrides = {}) {
164
177
  hidden: v.hidden || void 0,
165
178
  removed: removedOf(v.removed, String(v.id)),
166
179
  ...versionSeoOf(v, String(v.id)),
167
- ...versionPathOf(v, String(v.id))
180
+ ...versionPathOf(v, String(v.id), productsBlock)
168
181
  }));
169
182
  const locales = (content.locales ?? raw.locales ?? []).map((l) => ({
170
183
  id: unitId("locales", l.id),
@@ -267,6 +280,7 @@ function parseConfig(text, overrides = {}) {
267
280
  }
268
281
  if (dv.noindex) throw new ConfigError(`versions: "${dv.id}" is the defaultVersion and has noindex: true; older versions' pages name it as their canonical`);
269
282
  }
283
+ if (productsBlock) Object.assign(cfg, productsOf(content, raw, cfg));
270
284
  return cfg;
271
285
  }
272
286
  var TAG_COLORS = /* @__PURE__ */ new Set(["blue", "amber", "emerald", "violet", "rose", "slate"]);
@@ -392,11 +406,41 @@ function versionSeoOf(v, id) {
392
406
  ...typeof v.sitemap === "boolean" ? { sitemap: v.sitemap } : {}
393
407
  };
394
408
  }
395
- function versionPathOf(v, id) {
409
+ function versionPathOf(v, id, products = false) {
396
410
  if (v.path === void 0 || v.path === null) return {};
397
- if (v.path !== "") throw new ConfigError(`versions: "${id}".path can only be "" (serve the version at the root)`);
411
+ if (products && v.path !== "") return { path: String(v.path) };
412
+ if (v.path !== "") throw new ConfigError(`versions: "${id}".path can only be "" (serve the version at the root); another segment needs content.products:`);
398
413
  return { path: "" };
399
414
  }
415
+ function productsOf(content, raw, cfg) {
416
+ const siteVersions = content.versions ?? raw.versions;
417
+ const written = (Array.isArray(content.sources) ? content.sources : []).filter((s) => typeof s?.path === "string");
418
+ const sources = written.length ? written : [{ path: "content" }];
419
+ const model = shared(() => productModel({ products: content.products, sources, versions: siteVersions, defaultVersion: content.defaultVersion ?? raw.defaultVersion, locales: cfg.locales, defaultLocale: cfg.defaultLocale }));
420
+ if (!model) return {};
421
+ const productVersions = /* @__PURE__ */ new Map();
422
+ for (const p of model.products) {
423
+ const rawList = (p.name ? content.products[p.name]?.versions : void 0) ?? siteVersions ?? [];
424
+ const versions = p.versions.map((v) => {
425
+ const r = rawList.find((x) => String(x?.id) === v.id) ?? {};
426
+ return {
427
+ id: v.id,
428
+ label: v.label,
429
+ ...v.eol ? { eol: true } : {},
430
+ ...v.prerelease ? { prerelease: true } : {},
431
+ ...v.hidden ? { hidden: true } : {},
432
+ ...r.removed != null ? { removed: removedOf(r.removed, v.id) } : {},
433
+ ...v.canonical ? { canonical: v.canonical } : {},
434
+ ...v.noindex !== void 0 ? { noindex: v.noindex } : {},
435
+ ...v.sitemap !== void 0 ? { sitemap: v.sitemap } : {},
436
+ ...v.path !== v.id ? { path: v.path } : {},
437
+ ...v.banner ? { banner: v.banner } : {}
438
+ };
439
+ });
440
+ productVersions.set(p.name ?? "", { versions, ...p.defaultVersion ? { defaultVersion: p.defaultVersion } : {} });
441
+ }
442
+ return { products: model, productVersions };
443
+ }
400
444
  function unitOfPath(units, sitePath2) {
401
445
  let best;
402
446
  for (const u of units) {
@@ -406,7 +450,12 @@ function unitOfPath(units, sitePath2) {
406
450
  }
407
451
  return best;
408
452
  }
453
+ function groupPrefix(u) {
454
+ return [u.product, u.locale].filter(Boolean).join("/");
455
+ }
456
+ var sameProduct = (a, b) => (a.product ?? "") === (b.product ?? "");
409
457
  function buildUnits(cfg) {
458
+ if (cfg.products) return productUnits(cfg, cfg.products);
410
459
  const withDefaults2 = cfg.sources.map((s) => ({
411
460
  source: s,
412
461
  version: s.version ?? cfg.defaultVersion,
@@ -424,26 +473,26 @@ function buildUnits(cfg) {
424
473
  }
425
474
  }
426
475
  const namespaces = isNamespaceAxisActive(cfg.sources.filter((s) => !s.mount));
427
- if (namespaces && (versionAxis || localeAxis)) {
428
- throw new ConfigError("named sources (content.sources name:) cannot be combined with versions or locales yet (plans/openapi-sources.md)");
429
- }
476
+ const products = namespaces && (versionAxis || localeAxis);
430
477
  for (const s of cfg.sources) {
431
478
  if (s.from === "openapi" && cfg.sources.length > 1 && !s.name && !s.mount) throw new ConfigError(`content.sources[${s.path}]: an OpenAPI source beside other sources needs a name: or a mount:, so its pages do not land on theirs`);
432
479
  }
433
480
  const units = /* @__PURE__ */ new Map();
434
481
  for (const u of withDefaults2) {
435
482
  const atRoot = cfg.versions.find((v) => v.id === u.version)?.path === "";
436
- const prefix = [localeAxis ? u.locale : void 0, versionAxis && !atRoot ? u.version : void 0].filter(Boolean).join("/");
437
- const member = u.source.mount ? { source: u.source, namespace: "", mount: u.source.mount } : { source: u.source, namespace: namespaceSegments(u.source, cfg.sources).join("/") };
483
+ const product = products && !u.source.mount ? sourceNamespace(u.source) : void 0;
484
+ const prefix = [product, localeAxis ? u.locale : void 0, versionAxis && !atRoot ? u.version : void 0].filter(Boolean).join("/");
485
+ const member = u.source.mount ? { source: u.source, namespace: "", mount: u.source.mount } : { source: u.source, namespace: products ? "" : namespaceSegments(u.source, cfg.sources).join("/") };
438
486
  const unit = units.get(prefix);
439
487
  if (!unit) {
440
- units.set(prefix, { source: u.source, members: [member], version: versionAxis ? u.version : void 0, locale: localeAxis ? u.locale : void 0, prefix });
488
+ units.set(prefix, { source: u.source, members: [member], version: versionAxis ? u.version : void 0, ...versionAxis && u.version ? { versionPath: atRoot ? "" : u.version } : {}, locale: localeAxis ? u.locale : void 0, ...product ? { product } : {}, prefix });
441
489
  continue;
442
490
  }
443
491
  if (member.mount) {
444
492
  const taken = unit.members.find((m) => m.mount === member.mount);
445
493
  if (taken) throw new ConfigError(`two sources are mounted at "${member.mount}" in the same version and locale (${taken.source.path}, ${u.source.path})`);
446
494
  } else if (unit.members.some((m) => !m.mount)) {
495
+ if (products) throw new ConfigError(`two sources publish to "${prefix || "/"}" (${unit.members.find((m) => !m.mount).source.path}, ${u.source.path}): give each its own name, version or locale`);
447
496
  if (!namespaces) throw new ConfigError(`two sources publish to the same prefix "${prefix || "/"}"`);
448
497
  if (unit.members.some((m) => !m.mount && m.namespace === member.namespace)) {
449
498
  throw new ConfigError(member.namespace ? `two sources are named "${member.namespace}"` : `two unnamed sources publish to the same prefix "${prefix || "/"}"`);
@@ -453,6 +502,16 @@ function buildUnits(cfg) {
453
502
  const main = unit.members.find((m) => m.source === unit.source);
454
503
  if (!member.namespace && !member.mount && (main.namespace || main.mount)) unit.source = u.source;
455
504
  }
505
+ if (products) {
506
+ const names = new Set([...units.values()].map((u) => u.product).filter(Boolean));
507
+ for (const unit of units.values()) {
508
+ const main = unit.members.find((m) => !m.mount);
509
+ for (const m of unit.members.filter((x) => x.mount)) {
510
+ if (!main) throw new ConfigError(`content.sources[${m.source.path}]: a mounted source goes beside the unnamed source of its version and locale, and there is none at "${unit.prefix || "/"}"; a mount inside a named source's version is not supported yet`);
511
+ if (names.has(m.mount.split("/")[0])) throw new ConfigError(`content.sources[${m.source.path}]: mount "${m.mount}" starts with the name of a source, where that source's pages are; mount it elsewhere`);
512
+ }
513
+ }
514
+ }
456
515
  for (const unit of units.values()) {
457
516
  for (const m of unit.members) {
458
517
  const ns = m.mount && unit.members.find((o) => o.namespace && m.mount.split("/")[0] === o.namespace);
@@ -461,6 +520,43 @@ function buildUnits(cfg) {
461
520
  }
462
521
  return [...units.values()];
463
522
  }
523
+ function productUnits(cfg, model) {
524
+ const units = /* @__PURE__ */ new Map();
525
+ for (const s of cfg.sources) {
526
+ const key = shared(() => sourceUnit(model, s, cfg.defaultLocale));
527
+ const prefix = shared(() => unitSegments(model, key)).join("/");
528
+ const member = s.mount ? { source: s, namespace: "", mount: s.mount } : { source: s, namespace: "" };
529
+ const unit = units.get(prefix);
530
+ if (!unit) {
531
+ const versionPath = key.version === void 0 ? void 0 : shared(() => productOf(model, key.product)).versions.find((v) => v.id === key.version)?.path;
532
+ units.set(prefix, {
533
+ source: s,
534
+ members: [member],
535
+ ...key.version !== void 0 ? { version: key.version, versionPath } : {},
536
+ ...key.locale !== void 0 ? { locale: key.locale } : {},
537
+ ...key.product ? { product: key.product } : {},
538
+ prefix
539
+ });
540
+ continue;
541
+ }
542
+ if (member.mount) {
543
+ const taken = unit.members.find((m) => m.mount === member.mount);
544
+ if (taken) throw new ConfigError(`two sources are mounted at "${member.mount}" in the same version and locale (${taken.source.path}, ${s.path})`);
545
+ } else if (unit.members.some((m) => !m.mount)) {
546
+ throw new ConfigError(`two sources publish to "${prefix || "/"}" (${unit.members.find((m) => !m.mount).source.path}, ${s.path}): give each its own name, version or locale`);
547
+ }
548
+ unit.members.push(member);
549
+ if (!member.mount) unit.source = s;
550
+ }
551
+ const names = new Set(model.products.map((p) => p.name).filter(Boolean));
552
+ for (const unit of units.values()) {
553
+ if (unit.prefix !== "") continue;
554
+ for (const m of unit.members) {
555
+ if (m.mount && names.has(m.mount.split("/")[0])) throw new ConfigError(`content.sources[${m.source.path}]: mount "${m.mount}" starts with the name of a product, where that product's pages are; mount it elsewhere`);
556
+ }
557
+ }
558
+ return [...units.values()];
559
+ }
464
560
  function memberPath(unit, projectPath2) {
465
561
  let best;
466
562
  for (const m of unit.members) {
@@ -1069,16 +1165,19 @@ function parseFrontMatter(text) {
1069
1165
  return {};
1070
1166
  }
1071
1167
  }
1168
+ var tableKey = (u) => u.product ? `${u.product}\0${u.locale ?? ""}` : u.locale ?? "";
1072
1169
  async function defaultTables(cfg, units, built, files, read, warn = () => {
1073
1170
  }, fm) {
1074
1171
  const tables = /* @__PURE__ */ new Map();
1075
- if (!cfg.defaultVersion || !built.some((u) => u.version && u.version !== cfg.defaultVersion)) return tables;
1076
- for (const unit of units.filter((u) => u.version === cfg.defaultVersion)) {
1172
+ const dv = (u) => versionsOf(cfg, u.product).defaultVersion;
1173
+ const older = (u) => !!u.version && !!dv(u) && u.version !== dv(u);
1174
+ if (!built.some(older)) return tables;
1175
+ for (const unit of units.filter((u) => u.version !== void 0 && u.version === dv(u))) {
1077
1176
  const t = await scan(cfg, unit, units, files, fm ?? frontMatterCache(read), read);
1078
- if (!t.pages.size && built.some((u) => u.version !== cfg.defaultVersion && u.locale === unit.locale)) {
1079
- warn(`[canonical] the default version "${cfg.defaultVersion}" has no pages under ${unit.source.path}, so older versions' pages keep themselves as canonical`);
1177
+ if (!t.pages.size && built.some((u) => older(u) && u.locale === unit.locale && sameProduct(u, unit))) {
1178
+ warn(`[canonical] the default version "${dv(unit)}" has no pages under ${unit.source.path}, so older versions' pages keep themselves as canonical`);
1080
1179
  }
1081
- tables.set(unit.locale ?? "", t);
1180
+ tables.set(tableKey(unit), t);
1082
1181
  }
1083
1182
  return tables;
1084
1183
  }
@@ -1163,11 +1262,12 @@ async function scan(cfg, unit, units, files, frontMatterOf, read, mode = { kind:
1163
1262
  return { prefix: unit.prefix, pages, redirects };
1164
1263
  }
1165
1264
  function pageCanonical(cfg, unit, tables, page, urls) {
1166
- const table = tables.get(unit.locale ?? "");
1167
- const version = cfg.versions.find((v) => v.id === unit.version);
1265
+ const table = tables.get(tableKey(unit));
1266
+ const { versions, defaultVersion } = versionsOf(cfg, unit.product);
1267
+ const version = versions.find((v) => v.id === unit.version);
1168
1268
  const fmCanonical = typeof page.frontmatter.canonicalUrl === "string" ? page.frontmatter.canonicalUrl : void 0;
1169
1269
  const d = decideCanonical({
1170
- isDefault: !unit.version || unit.version === cfg.defaultVersion || !table,
1270
+ isDefault: !unit.version || unit.version === defaultVersion || !table,
1171
1271
  path: page.slug || "index",
1172
1272
  version,
1173
1273
  frontmatter: fmCanonical,
@@ -1312,31 +1412,31 @@ var CLIENT_CANDIDATES = ['""', '"&"', '"\\\\$&")).join("|")})', '"\\\\$&")}"', '
1312
1412
  var SITE_NOTICES = `Third-party software in this site's _dogsbay/ files (dogsbay.js, site.css) and pages
1313
1413
 
1314
1414
  ========================================================================
1315
- @dogsbay/elements@0.2.0-beta.144 (MIT)
1415
+ @dogsbay/elements@0.2.0-beta.146 (MIT)
1316
1416
  https://github.com/dogsbay/dogsbay.git
1317
1417
 
1318
1418
  Licensed under MIT; the package ships no licence file.
1319
1419
 
1320
1420
  ========================================================================
1321
- @dogsbay/primitives@0.2.0-beta.144 (MIT)
1421
+ @dogsbay/primitives@0.2.0-beta.146 (MIT)
1322
1422
  https://github.com/dogsbay/dogsbay.git
1323
1423
 
1324
1424
  Licensed under MIT; the package ships no licence file.
1325
1425
 
1326
1426
  ========================================================================
1327
- @dogsbay/render-html@0.2.0-beta.144 (MIT)
1427
+ @dogsbay/render-html@0.2.0-beta.146 (MIT)
1328
1428
  https://github.com/dogsbay/dogsbay.git
1329
1429
 
1330
1430
  Licensed under MIT; the package ships no licence file.
1331
1431
 
1332
1432
  ========================================================================
1333
- @dogsbay/search@0.2.0-beta.144 (MIT)
1433
+ @dogsbay/search@0.2.0-beta.146 (MIT)
1334
1434
  https://github.com/dogsbay/dogsbay.git
1335
1435
 
1336
1436
  Licensed under MIT; the package ships no licence file.
1337
1437
 
1338
1438
  ========================================================================
1339
- @dogsbay/ui@0.2.0-beta.144 (MIT)
1439
+ @dogsbay/ui@0.2.0-beta.146 (MIT)
1340
1440
  https://github.com/dogsbay/dogsbay.git
1341
1441
 
1342
1442
  Licensed under MIT; the package ships no licence file.
@@ -2498,6 +2598,8 @@ var EN = {
2498
2598
  prerelease: "pre-release",
2499
2599
  latest: "latest",
2500
2600
  oldVersion: "You are viewing documentation for an older version.",
2601
+ eolVersion: "This version is no longer maintained.",
2602
+ prereleaseVersion: "This version is not released yet.",
2501
2603
  goLatest: "Go to the latest version",
2502
2604
  notFound: "Page not found",
2503
2605
  notFoundBody: "The page you asked for does not exist in this version of the documentation.",
@@ -3064,7 +3166,8 @@ function diagramStore(input, cfg) {
3064
3166
  async function siteCssBuilder(input) {
3065
3167
  return input.css ?? (await import("./chunks/tailwind-fallback-KPRFHW3C.mjs")).buildSiteCss;
3066
3168
  }
3067
- var SITE_FILES = /* @__PURE__ */ new Set(["robots.txt", "_headers", "_redirects", "sitemap-index.xml"]);
3169
+ var SITE_FILES = /* @__PURE__ */ new Set(["robots.txt", "sitemap-index.xml"]);
3170
+ var MERGED_FILES = /* @__PURE__ */ new Set(["_headers", "_redirects"]);
3068
3171
  function reporter(warnings, problems) {
3069
3172
  return (code, text, at = {}) => {
3070
3173
  const t = detach(text);
@@ -3210,6 +3313,7 @@ async function build(input, hooks = {}) {
3210
3313
  const allUnits = buildUnits(cfg);
3211
3314
  const units = selectUnits(allUnits, input.units);
3212
3315
  checkRootVersion(allUnits, input.files);
3316
+ checkProductFolders(allUnits, input.files);
3213
3317
  const out = /* @__PURE__ */ new Map();
3214
3318
  const sink = input.sink;
3215
3319
  let classes = new Set(CLIENT_CANDIDATES);
@@ -3238,7 +3342,7 @@ async function build(input, hooks = {}) {
3238
3342
  phase.init = now() - t0;
3239
3343
  let pages = 0;
3240
3344
  let assets = 0;
3241
- const state = { redirects: [], notes: /* @__PURE__ */ new Map(), navCache: navCache(), written: /* @__PURE__ */ new Set(), stubs: [], pageOut: /* @__PURE__ */ new Map(), sitemaps: /* @__PURE__ */ new Map(), supplied: /* @__PURE__ */ new Set(), usedMath: false, diagrams: diagramStore(input, cfg), scan: hooks.keep || hooks.lazy ? new ScanCache() : void 0, parseCache: input.parseCacheBytes === 0 ? false : new ParseCache(input.parseCacheBytes ?? DEFAULT_PARSE_CACHE_BYTES), templateBudget: templateBudget(input.untrusted === true, input.templateMs), dataCache: new DataCache(input.dataCacheBytes ?? DEFAULT_DATA_CACHE_BYTES) };
3345
+ const state = { redirects: [], notes: /* @__PURE__ */ new Map(), navCache: navCache(), written: /* @__PURE__ */ new Set(), stubs: [], pageOut: /* @__PURE__ */ new Map(), sitemaps: /* @__PURE__ */ new Map(), supplied: /* @__PURE__ */ new Set(), hostFiles: /* @__PURE__ */ new Map(), usedMath: false, diagrams: diagramStore(input, cfg), scan: hooks.keep || hooks.lazy ? new ScanCache() : void 0, parseCache: input.parseCacheBytes === 0 ? false : new ParseCache(input.parseCacheBytes ?? DEFAULT_PARSE_CACHE_BYTES), templateBudget: templateBudget(input.untrusted === true, input.templateMs), dataCache: new DataCache(input.dataCacheBytes ?? DEFAULT_DATA_CACHE_BYTES) };
3242
3346
  for (const r of cfg.redirects) {
3243
3347
  const from = sitePath(r.from, cfg.basePath);
3244
3348
  if (!from) {
@@ -3292,20 +3396,36 @@ async function build(input, hooks = {}) {
3292
3396
  defaultLocale: cfg.defaultLocale,
3293
3397
  versions: cfg.versions,
3294
3398
  locales: cfg.locales,
3295
- units: allUnits.map((u) => ({ prefix: u.prefix, version: u.version, locale: u.locale }))
3399
+ units: allUnits.map((u) => ({ prefix: u.prefix, version: u.version, locale: u.locale, ...u.product ? { product: u.product } : {}, ...cfg.products && u.versionPath !== u.version ? { versionPath: u.versionPath } : {} })),
3400
+ // With content.products, each product's own versions and default (key "" for the unnamed source's).
3401
+ ...cfg.productVersions ? { products: Object.fromEntries(cfg.productVersions) } : {}
3296
3402
  // As nav.js: a host may inline the script, and a name with "<\/script>" must not close it.
3297
3403
  }).replace(/<\//g, "<\\/")};
3298
3404
  `
3299
3405
  );
3406
+ if (cfg.products) await emit("_dogsbay/versions.js", `${toScript("__DOGSBAY_VERSIONS__", siteFiles(cfg.products, []).versions)}
3407
+ `);
3300
3408
  const def = defaultUnit(allUnits, cfg);
3301
3409
  const tSite = now();
3410
+ const rootUnit = allUnits.find((u) => u.prefix === "");
3411
+ if (rootUnit && !input.untrusted) {
3412
+ for (const n of MERGED_FILES) {
3413
+ const own = input.files.get(sourceRoot(rootUnit.source) + n);
3414
+ if (own !== void 0) state.hostFiles.set(n, own);
3415
+ }
3416
+ }
3302
3417
  await emitSiteFiles(cfg, allUnits, units, def, state, emit, report, input.onRedirects);
3303
3418
  phase.site = (phase.site ?? 0) + now() - tSite;
3304
3419
  if (def && def.prefix) {
3305
3420
  await emit("index.html", redirectPage(`${def.prefix}/index.html`, cfg.name));
3306
3421
  await emit("404.html", redirectPage(`${def.prefix}/404.html`, cfg.name));
3422
+ }
3423
+ if (cfg.products) {
3424
+ for (const [from, to] of productRootRedirects(cfg.products)) await emit(`${from.slice(1)}index.html`, redirectPage(`${relativeHome(from, to)}index.html`, cfg.name));
3425
+ } else if (def && def.prefix) {
3307
3426
  for (const u of allUnits) {
3308
- if (u.locale && u.version && u.version === cfg.defaultVersion && u.prefix !== u.locale) await emit(`${u.locale}/index.html`, redirectPage(`${u.version}/index.html`, cfg.name));
3427
+ const root = groupPrefix(u);
3428
+ if (root && u.version && u.version === cfg.defaultVersion && u.prefix !== root) await emit(`${root}/index.html`, redirectPage(`${u.version}/index.html`, cfg.name));
3309
3429
  }
3310
3430
  }
3311
3431
  state.stubs = await writeTranslationStubs(cfg, allUnits, units, translations, state, emit);
@@ -3322,7 +3442,10 @@ async function build(input, hooks = {}) {
3322
3442
  }
3323
3443
  function selectUnits(allUnits, names) {
3324
3444
  if (!names) return allUnits;
3325
- const named = (u, name) => u.prefix === name || u.version !== void 0 && u.version === name && u.prefix === (u.locale ?? "");
3445
+ const named = (u, name) => {
3446
+ if (u.product) return u.product === name || u.prefix === name && u.prefix !== groupPrefix(u) || u.version !== void 0 && name === `${groupPrefix(u)}/${u.version}`;
3447
+ return u.prefix === name || u.version !== void 0 && (u.version === name && u.prefix === groupPrefix(u) || [groupPrefix(u), u.version].filter(Boolean).join("/") === name);
3448
+ };
3326
3449
  const unknown = names.filter((p) => !allUnits.some((u) => named(u, p)));
3327
3450
  if (unknown.length) {
3328
3451
  const list = allUnits.map((u) => u.prefix ? `"${u.prefix}"` : `"${u.version ?? ""}" (at the root)`).join(", ");
@@ -3332,13 +3455,13 @@ function selectUnits(allUnits, names) {
3332
3455
  }
3333
3456
  function checkRootVersion(allUnits, files) {
3334
3457
  const rootOf = (x) => x.source.path === "." ? "" : x.source.path.replace(/^\.\//, "").replace(/\/+$/, "") + "/";
3335
- for (const u of allUnits.filter((x) => x.version && x.prefix === (x.locale ?? ""))) {
3458
+ for (const u of allUnits.filter((x) => x.version && x.prefix === groupPrefix(x))) {
3336
3459
  const root = rootOf(u);
3337
3460
  const nested = allUnits.find((x) => x !== u && rootOf(x).startsWith(root) && rootOf(x) !== root);
3338
3461
  if (nested) {
3339
3462
  throw new ConfigError(`source "${nested.source.path}" is inside "${u.source.path}", the source of version "${u.version}" served at the root; give each version its own folder`);
3340
3463
  }
3341
- const segs = new Set(allUnits.filter((x) => x !== u && x.locale === u.locale && x.version).map((x) => x.version));
3464
+ const segs = new Set(allUnits.filter((x) => x !== u && x.locale === u.locale && sameProduct(x, u) && x.versionPath).map((x) => x.versionPath));
3342
3465
  if (!segs.size) continue;
3343
3466
  for (const k of files.keys()) {
3344
3467
  if (!k.startsWith(root)) continue;
@@ -3346,7 +3469,26 @@ function checkRootVersion(allUnits, files) {
3346
3469
  if (rel.split("/").some((s) => s.startsWith("_") || s.startsWith("."))) continue;
3347
3470
  const first = rel.split("/")[0].replace(/\.(md|markdown)$/i, "");
3348
3471
  if (segs.has(first) && (rel.includes("/") || /\.(md|markdown)$/i.test(rel))) {
3349
- throw new ConfigError(`${k} is at ${[u.prefix, first].filter(Boolean).join("/")}/\u2026, where version "${first}" is served (version "${u.version}" is at the root); rename the folder or the page`);
3472
+ const other = allUnits.find((x) => x.versionPath === first && x.locale === u.locale && sameProduct(x, u));
3473
+ throw new ConfigError(`${k} is at ${[u.prefix, first].filter(Boolean).join("/")}/\u2026, where version "${other.version}" is served (version "${u.version}" is at the root); rename the folder or the page`);
3474
+ }
3475
+ }
3476
+ }
3477
+ }
3478
+ function checkProductFolders(allUnits, files) {
3479
+ const names = new Set(allUnits.map((u) => u.product).filter((p) => !!p));
3480
+ if (!names.size) return;
3481
+ for (const u of allUnits.filter((x) => !x.product && x.prefix === "")) {
3482
+ const roots = u.members.filter((m) => !m.mount && m.source.from !== "openapi").map((m) => sourceRoot(m.source));
3483
+ const others = allUnits.filter((x) => x !== u).flatMap((x) => x.members.map((m) => sourceRoot(m.source))).filter(Boolean);
3484
+ for (const k of files.keys()) {
3485
+ const root = roots.find((r) => k.startsWith(r));
3486
+ if (root === void 0 || others.some((o) => k.startsWith(o) && o.length > root.length)) continue;
3487
+ const rel = k.slice(root.length);
3488
+ if (rel.split("/").some((seg) => seg.startsWith("_") || seg.startsWith("."))) continue;
3489
+ const first = rel.split("/")[0].replace(/\.(md|markdown)$/i, "");
3490
+ if (names.has(first) && (rel.includes("/") || /\.(md|markdown)$/i.test(rel))) {
3491
+ throw new ConfigError(`${k} is at ${first}/\u2026, where the source named "${first}" is published; rename the folder or the page`);
3350
3492
  }
3351
3493
  }
3352
3494
  }
@@ -3357,15 +3499,23 @@ async function emitSiteFiles(cfg, all, built, def, state, emit, report, onRedire
3357
3499
  if (state.supplied.has(p)) report("site", `[site] ${p} comes from the content, so the generated one is not written`);
3358
3500
  else await emit(p, content);
3359
3501
  };
3502
+ const cloudflare = cfg.deployTarget === "cloudflare-workers";
3503
+ const merged = async (p, generated, merge) => {
3504
+ const own = state.hostFiles.get(p);
3505
+ if (own === void 0 && generated === void 0) return;
3506
+ const { text, notes } = merge(own === void 0 ? "" : await readText(own), generated, cloudflare);
3507
+ for (const n of notes) report("site", `[site] ${n}`);
3508
+ await emit(p, text);
3509
+ };
3360
3510
  await put("robots.txt", buildRobotsTxt(cfg, defPrefix));
3361
- if (cfg.llmsTxt) await put("_headers", buildHeadersFile(cfg, defPrefix));
3511
+ await merged("_headers", cfg.llmsTxt ? buildHeadersFile(cfg, defPrefix) : void 0, mergeHeaders);
3362
3512
  if (siteBase(cfg)) {
3363
3513
  const maps = all.flatMap((u) => state.sitemaps.get(u.prefix) ?? [[u.prefix, "sitemap-0.xml"].filter(Boolean).join("/")]);
3364
3514
  await put("sitemap-index.xml", buildSitemapIndex(cfg, maps));
3365
3515
  } else if (cfg.url) {
3366
3516
  report("config", `[site] site.url "${cfg.url}" has no http:// or https://, so canonical links, the sitemap and absolute llms.txt links are left out`);
3367
3517
  }
3368
- if (!state.redirects.length) return;
3518
+ if (!state.redirects.length) return merged("_redirects", void 0, mergeRedirects);
3369
3519
  const known = (p) => {
3370
3520
  const u = unitOfPath(all, p);
3371
3521
  return !!u && built.includes(u);
@@ -3375,9 +3525,15 @@ async function emitSiteFiles(cfg, all, built, def, state, emit, report, onRedire
3375
3525
  for (const m of w) report("redirects", `[redirects] ${m}`);
3376
3526
  onRedirects?.(rules);
3377
3527
  for (const r of rules) await emit(stubFile(r), renderRedirectPage(r, cfg));
3378
- if (cfg.deployTarget === "cloudflare-workers" && rules.length) await put("_redirects", renderRedirectsFile(rules, cfg));
3528
+ const ownRedirects = state.hostFiles.has("_redirects");
3529
+ await merged("_redirects", (cloudflare || ownRedirects) && rules.length ? renderRedirectsFile(rules, cfg) : void 0, mergeRedirects);
3379
3530
  }
3380
3531
  function defaultUnit(units, cfg) {
3532
+ if (cfg.products) {
3533
+ const target = siteRootTarget(cfg.products);
3534
+ if (target) return units.find((u) => `/${u.prefix}/` === target || u.prefix === "" && target === "/") ?? units[0];
3535
+ units = units.filter((u) => !u.product);
3536
+ }
3381
3537
  return units.find((u) => (u.version ?? cfg.defaultVersion) === cfg.defaultVersion && (u.locale ?? cfg.defaultLocale) === cfg.defaultLocale) ?? units[0];
3382
3538
  }
3383
3539
  var mathLoad;
@@ -4351,6 +4507,8 @@ ${marker}${title}${marker}`, path, attributesOf(path), includesFor(path, read2),
4351
4507
  t = now();
4352
4508
  for (const a of assetPaths) {
4353
4509
  if (SITE_FILES.has(prefix + a)) state.supplied.add(prefix + a);
4510
+ if (MERGED_FILES.has(prefix + a)) continue;
4511
+ if (MERGED_FILES.has(a)) warn("site", `${a} is copied to ${prefix}${a}, where hosts do not read it: they read _headers and _redirects at the site root only, from the unit served there`, { file: abs(a) });
4354
4512
  await emit(prefix + a, files.get(a));
4355
4513
  }
4356
4514
  const pagesJs = `(window.__DOGSBAY_PAGES__=window.__DOGSBAY_PAGES__||{})[${JSON.stringify(unit.prefix)}]=${JSON.stringify([...[...infos.values()].filter((i) => i.slug !== replaced).map((i) => i.out), ...generatedOuts])};
@@ -4863,13 +5021,14 @@ function switchOptions(kind, unit, all, cfg, out, siteRoot, t) {
4863
5021
  const axis = kind === "version" ? "version" : "locale";
4864
5022
  const other = kind === "version" ? "locale" : "version";
4865
5023
  if (!unit[axis]) return [];
4866
- const peers = all.filter((u) => u[other] === unit[other] && u[axis]);
4867
- const defs = kind === "version" ? cfg.versions : cfg.locales;
5024
+ const peers = all.filter((u) => u[other] === unit[other] && sameProduct(u, unit) && u[axis]);
5025
+ const { versions, defaultVersion } = versionsOf(cfg, unit.product);
5026
+ const defs = kind === "version" ? versions : cfg.locales;
4868
5027
  const ordered = defs.length ? defs.map((d) => peers.find((u) => u[axis] === d.id)).filter((u) => !!u) : peers;
4869
- return ordered.filter((u) => !(kind === "version" && cfg.versions.find((v) => v.id === u.version)?.hidden && u !== unit)).map((u) => {
4870
- const v = kind === "version" ? cfg.versions.find((d) => d.id === u.version) : void 0;
5028
+ return ordered.filter((u) => !(kind === "version" && versions.find((v) => v.id === u.version)?.hidden && u !== unit)).map((u) => {
5029
+ const v = kind === "version" ? versions.find((d) => d.id === u.version) : void 0;
4871
5030
  const l = kind === "locale" ? cfg.locales.find((d) => d.id === u.locale) : void 0;
4872
- const note = v?.eol ? t("eol") : v?.prerelease ? t("prerelease") : v && v.id === cfg.defaultVersion && v.id !== "latest" ? t("latest") : void 0;
5031
+ const note = v?.eol ? t("eol") : v?.prerelease ? t("prerelease") : v && v.id === defaultVersion && v.id !== "latest" ? t("latest") : void 0;
4873
5032
  return {
4874
5033
  label: v?.label ?? l?.label ?? String(u[axis]),
4875
5034
  href: `${siteRoot}${u.prefix ? u.prefix + "/" : ""}${out}`,
@@ -4880,19 +5039,28 @@ function switchOptions(kind, unit, all, cfg, out, siteRoot, t) {
4880
5039
  });
4881
5040
  }
4882
5041
  function versionBanner(unit, all, cfg, out, siteRoot, t) {
5042
+ const link = (latest2) => ` <a href="${esc3(`${siteRoot}${latest2.prefix ? latest2.prefix + "/" : ""}${out}`)}" data-db-version-link data-prefix="${esc3(latest2.prefix)}">${esc3(t("goLatest"))}</a>`;
5043
+ if (cfg.products) {
5044
+ if (unit.version === void 0 || versionsOf(cfg, unit.product).versions.find((d) => d.id === unit.version)?.hidden) return void 0;
5045
+ const b = shared(() => unitOf(cfg.products, { product: unit.product, locale: unit.locale, version: unit.version })).banner;
5046
+ if (!b) return void 0;
5047
+ const text = esc3(b.kind === "custom" ? b.text : t(b.kind === "eol" ? "eolVersion" : b.kind === "prerelease" ? "prereleaseVersion" : "oldVersion"));
5048
+ const latest2 = b.latest && all.find((u) => `/${u.prefix ? u.prefix + "/" : ""}` === b.latest.home);
5049
+ return latest2 ? text + link(latest2) : text;
5050
+ }
4883
5051
  if (!unit.version || !cfg.defaultVersion || unit.version === cfg.defaultVersion) return void 0;
4884
5052
  const v = cfg.versions.find((d) => d.id === unit.version);
4885
5053
  if (v?.hidden) return void 0;
4886
- const latest = all.find((u) => u.version === cfg.defaultVersion && u.locale === unit.locale);
5054
+ const latest = all.find((u) => u.version === cfg.defaultVersion && u.locale === unit.locale && sameProduct(u, unit));
4887
5055
  if (!latest) return void 0;
4888
- return `${esc3(t("oldVersion"))} <a href="${esc3(`${siteRoot}${latest.prefix ? latest.prefix + "/" : ""}${out}`)}" data-db-version-link data-prefix="${esc3(latest.prefix)}">${esc3(t("goLatest"))}</a>`;
5056
+ return esc3(t("oldVersion")) + link(latest);
4889
5057
  }
4890
5058
  async function writeTranslationStubs(cfg, all, built, tables, state, emit, exists = () => false) {
4891
5059
  const def = cfg.defaultLocale;
4892
5060
  if (!def || !tables.size) return [];
4893
- const unitOf = (locale, version) => all.find((u) => u.locale === locale && u.version === version);
5061
+ const unitOf2 = (locale, version) => all.find((u) => u.locale === locale && u.version === version && !u.product);
4894
5062
  const missing = missingTranslations(
4895
- all.filter((u) => u.locale).map((u) => {
5063
+ all.filter((u) => u.locale && !u.product).map((u) => {
4896
5064
  const t = tables.get(u.prefix);
4897
5065
  return { locale: u.locale, version: u.version, pages: !t ? [] : u.locale === def ? t.pages.keys() : [...t.pages.keys(), ...t.redirects.keys()] };
4898
5066
  }),
@@ -4903,9 +5071,9 @@ async function writeTranslationStubs(cfg, all, built, tables, state, emit, exist
4903
5071
  const written = [];
4904
5072
  for (const m of missing) {
4905
5073
  if (m.page === "404") continue;
4906
- const home = unitOf(def, m.version);
5074
+ const home = unitOf2(def, m.version);
4907
5075
  const out = tables.get(home.prefix).pages.get(m.page);
4908
- const target = unitOf(m.locale, m.version);
5076
+ const target = unitOf2(m.locale, m.version);
4909
5077
  if (!built.includes(target ?? home)) continue;
4910
5078
  const to = join(home.prefix, out);
4911
5079
  if (built.includes(home) && !pageFiles.has(to)) continue;
@@ -4916,6 +5084,10 @@ async function writeTranslationStubs(cfg, all, built, tables, state, emit, exist
4916
5084
  }
4917
5085
  return written;
4918
5086
  }
5087
+ function relativeHome(from, to) {
5088
+ if (to.startsWith(from)) return to.slice(from.length);
5089
+ return "../".repeat(from.split("/").filter(Boolean).length) + to.slice(1);
5090
+ }
4919
5091
  function redirectPage(target, name) {
4920
5092
  const u = esc3(target);
4921
5093
  return `<!doctype html><html><head><meta charset="utf-8"><title>${esc3(name)}</title><meta name="robots" content="noindex"><meta http-equiv="refresh" content="0; url=${u}"><link rel="canonical" href="${u}"><link rel="icon" href="data:,"></head><body><p><a href="${u}">${esc3(name)}</a></p></body></html>
@@ -4991,7 +5163,7 @@ function hreflangFor(cfg, unit, all, languages, slug) {
4991
5163
  const path = slug || "index";
4992
5164
  const alts = [];
4993
5165
  for (const u of all) {
4994
- if (!u.locale || u.version !== unit.version) continue;
5166
+ if (!u.locale || u.version !== unit.version || !sameProduct(u, unit)) continue;
4995
5167
  const out = languages.get(u.prefix)?.get(path);
4996
5168
  const url = out ? absoluteUrl(cfg, u.prefix, servedPath(out, cfg.linkStyle)) : void 0;
4997
5169
  if (url) alts.push({ lang: u.locale, url });
@@ -5003,7 +5175,7 @@ function hreflangFor(cfg, unit, all, languages, slug) {
5003
5175
  var CONFIG_FILES = /* @__PURE__ */ new Set(["dogsbay.config.yml", "dogsbay.config.yaml"]);
5004
5176
  var NAV_FILES2 = /(^|\/)nav\.(ya?ml|json)$/;
5005
5177
  var START_ONLY = /(^|\/)(_dogsbay\/search\/|llms(-full)?\.txt$)/;
5006
- var SITE_WIDE = /^(_dogsbay\/assets\/|_dogsbay\/site\.js$|robots\.txt$|_headers$|_redirects$|sitemap-index\.xml$)/;
5178
+ var SITE_WIDE = /^(_dogsbay\/assets\/|_dogsbay\/(site|versions)\.js$|robots\.txt$|_headers$|_redirects$|sitemap-index\.xml$)/;
5007
5179
  var UNIT_CSS = /(^|\/)_dogsbay\/site\.css$/;
5008
5180
  async function openDevSession(input, o = {}) {
5009
5181
  let out = /* @__PURE__ */ new Map();
@@ -5082,7 +5254,7 @@ async function openDevSession(input, o = {}) {
5082
5254
  if (o.lazy) for (const u of units.values()) for (const p of u.handle.pageOuts) stale.add(p);
5083
5255
  };
5084
5256
  const noteItems = () => [...ctx?.state.notes.values() ?? []].flat().map((n) => ({ ...n }));
5085
- const unitOf = (path) => {
5257
+ const unitOf2 = (path) => {
5086
5258
  let best;
5087
5259
  let bestLen = -1;
5088
5260
  for (const u of units.values()) {
@@ -5167,7 +5339,7 @@ async function openDevSession(input, o = {}) {
5167
5339
  const lazy = await textOf(c);
5168
5340
  return lazy ?? c;
5169
5341
  };
5170
- const pageFor = (source) => unitOf(source)?.handle.pageForSource(source);
5342
+ const pageFor = (source) => unitOf2(source)?.handle.pageForSource(source);
5171
5343
  return {
5172
5344
  get config() {
5173
5345
  return config;
@@ -5278,7 +5450,7 @@ async function openDevSession(input, o = {}) {
5278
5450
  if (CONFIG_FILES.has(p)) continue;
5279
5451
  const readers = [...units.values()].filter((u2) => u2.handle.navInputs.includes(p) || u2.unit.members.some((m) => m.source.nav === p));
5280
5452
  if (readers.length) {
5281
- for (const u2 of [...readers, unitOf(p)]) if (u2) lazy.add(u2.unit.prefix);
5453
+ for (const u2 of [...readers, unitOf2(p)]) if (u2) lazy.add(u2.unit.prefix);
5282
5454
  mark("unit");
5283
5455
  continue;
5284
5456
  }
@@ -5300,7 +5472,7 @@ async function openDevSession(input, o = {}) {
5300
5472
  }
5301
5473
  continue;
5302
5474
  }
5303
- const u = unitOf(p);
5475
+ const u = unitOf2(p);
5304
5476
  if (!u) continue;
5305
5477
  const content = input.files.get(p);
5306
5478
  if (/\.(md|markdown)$/i.test(p)) {
@@ -5326,6 +5498,7 @@ async function openDevSession(input, o = {}) {
5326
5498
  const at = memberPath(u.unit, p)?.path ?? p.slice(sourceRoot(u.unit.source).length);
5327
5499
  const key = u.unit.prefix ? `${u.unit.prefix}/${at}` : at;
5328
5500
  const sanitizing = (config.html ?? input.defaultHtml ?? "keep") === "sanitize";
5501
+ if (key === "_headers" || key === "_redirects") continue;
5329
5502
  if (copiedPath(at) && await assetVerdict(at, content, input.untrusted === true, sanitizing) === "copy") out.set(key, content);
5330
5503
  else out.delete(key);
5331
5504
  }
@@ -5352,10 +5525,12 @@ async function openDevSession(input, o = {}) {
5352
5525
  units.set(prefix, next);
5353
5526
  for (const o2 of handle.pageOuts) markStale(o2, next, true);
5354
5527
  }
5355
- if (config.defaultVersion && [...lazy].some((prefix) => units.get(prefix)?.unit.version === config.defaultVersion)) {
5528
+ const isDefault = (u) => u.version !== void 0 && u.version === versionsOf(config, u.product).defaultVersion;
5529
+ const changedDefaults = [...lazy].map((prefix) => units.get(prefix)?.unit).filter((u) => !!u && isDefault(u));
5530
+ if (changedDefaults.length) {
5356
5531
  const all = [...units.values()].map((u) => u.unit);
5357
5532
  ctx.state.canonical = await defaultTables(config, all, all, input.files, readText);
5358
- for (const u of units.values()) if (u.unit.version !== config.defaultVersion) for (const o2 of u.handle.pageOuts) markStale(o2, u);
5533
+ for (const u of units.values()) if (u.unit.version !== void 0 && !isDefault(u.unit) && changedDefaults.some((d) => (d.product ?? "") === (u.unit.product ?? ""))) for (const o2 of u.handle.pageOuts) markStale(o2, u);
5359
5534
  }
5360
5535
  if (lazy.size && ctx.state.languages) {
5361
5536
  const all = [...units.values()].map((u) => u.unit);
@@ -220,8 +220,10 @@ export interface SiteState {
220
220
  pageOut: Map<string, string>;
221
221
  /** Sitemap files, relative to the site root, by unit prefix. */
222
222
  sitemaps: Map<string, string[]>;
223
- /** Site-level files a content root supplies itself (robots.txt, _headers…); they win over generated ones. */
223
+ /** Site-level files a content root supplies itself (robots.txt, the sitemap index); they win over generated ones. */
224
224
  supplied: Set<string>;
225
+ /** The root's own `_headers` and `_redirects`, merged with the generated ones (@dogsbay/site-files host-files.ts). */
226
+ hostFiles: Map<string, FileContent>;
225
227
  /** A page rendered math, so the KaTeX stylesheet and fonts are written. */
226
228
  usedMath: boolean;
227
229
  /** Rendered diagrams, shared by the units of the build. */
@@ -423,10 +425,10 @@ export interface BuildHooks {
423
425
  /** Build one unit again without rendering its pages (they are rendered on request). */
424
426
  export declare function rebuildUnitLazy(ctx: BuildContext, unit: BuildUnit): Promise<UnitHandle>;
425
427
  export declare function build(input: BuildInput, hooks?: BuildHooks): Promise<BuildResult>;
426
- /** robots.txt, _headers, the sitemap index and redirects: the files that belong to the whole site. */
427
428
  /**
428
- * The units `--unit` names: by prefix, and a version served at the root
429
- * (prefix "" or the locale) also by its id. Throws on a name that is no unit.
429
+ * The units `--unit` names: by prefix; a version served at the root (prefix "", the locale or
430
+ * the product) also by its id, and inside a product by `<product>/<id>`; and a product by its
431
+ * name, which is all its versions and locales. Throws on a name that is no unit.
430
432
  */
431
433
  export declare function selectUnits(allUnits: BuildUnit[], names: string[] | undefined): BuildUnit[];
432
434
  /**
@@ -31,8 +31,7 @@ export type FrontMatterOf = (path: string, c: FileContent) => Promise<Record<str
31
31
  */
32
32
  export declare function frontMatterCache(read: Read): FrontMatterOf;
33
33
  /**
34
- * One table per language of the default version, keyed by locale (`""`
35
- * without a locale axis). Empty when none of the units being built is an
34
+ * One table per language of the default version and per product, keyed by `tableKey`. Empty when none of the units being built is an
36
35
  * older version (then nothing points elsewhere, and nothing is read).
37
36
  */
38
37
  export declare function defaultTables(cfg: SiteConfig, units: BuildUnit[], built: BuildUnit[], files: Map<string, FileContent>, read: Read, warn?: (m: string) => void, fm?: FrontMatterOf): Promise<Map<string, DefaultTable>>;
@@ -1,3 +1,4 @@
1
+ import { type ProductModel } from "@dogsbay/site-files";
1
2
  export type LinkStyle = "html" | "pretty";
2
3
  export type TocMode = "top" | "popover" | "rail" | "off";
3
4
  export interface VersionDef {
@@ -14,9 +15,26 @@ export interface VersionDef {
14
15
  noindex?: boolean;
15
16
  /** Override whether the version's pages are in the sitemap. */
16
17
  sitemap?: boolean;
17
- /** `""`: the version is served at the root (no version segment). Only the default version may set it. */
18
- path?: "";
18
+ /**
19
+ * The version's URL segment when it is not its id. `""`: served at the root (no version segment),
20
+ * only on the default version. Any other segment (`latest`) only inside `content.products:`.
21
+ */
22
+ path?: string;
23
+ /** The version's own banner text (`content.products:` only), instead of the standard one. */
24
+ banner?: string;
25
+ }
26
+ /** A call into @dogsbay/site-files' product model, its config errors reported as ours (dev and the CLI tell them apart). */
27
+ export declare function shared<T>(f: () => T): T;
28
+ /** The versions a unit's product has, and its default. */
29
+ export interface ProductVersions {
30
+ versions: VersionDef[];
31
+ defaultVersion?: string;
19
32
  }
33
+ /**
34
+ * A product's versions and default: its own with `content.products:`, else the site-wide `versions:`
35
+ * and `defaultVersion` (the unnamed source's, and every product's without the block).
36
+ */
37
+ export declare function versionsOf(cfg: Pick<SiteConfig, "versions" | "defaultVersion" | "productVersions">, product: string | undefined): ProductVersions;
20
38
  export interface LocaleDef {
21
39
  id: string;
22
40
  label: string;
@@ -72,6 +90,10 @@ export interface SiteConfig {
72
90
  routablesFrom: "all" | "nav";
73
91
  versions: VersionDef[];
74
92
  defaultVersion?: string;
93
+ /** `content.products:`, read by @dogsbay/site-files' `productModel` (plans/product-versions-ssg.md). Undefined without the block. */
94
+ products?: ProductModel;
95
+ /** With `products`: each product's versions and default (key `""` for the unnamed source's), read through `versionsOf`. */
96
+ productVersions?: Map<string, ProductVersions>;
75
97
  locales: LocaleDef[];
76
98
  defaultLocale?: string;
77
99
  search: boolean;
@@ -274,10 +296,26 @@ export interface BuildUnit {
274
296
  /** Every source the unit builds, the main one included, in config order. */
275
297
  members: UnitMember[];
276
298
  version?: string;
299
+ /** The version's URL segment: its id, its `path` (`latest`), or `""` when served without one. */
300
+ versionPath?: string;
277
301
  locale?: string;
278
- /** Output prefix inside the site, e.g. "en/3.31" or "" when neither axis is active. */
302
+ /**
303
+ * The named source the unit publishes, when named sources are combined with versions or locales
304
+ * (a product): its first prefix segment, as the CLI's `/<name>/<locale>/<version>/` order.
305
+ * Units of one product share it; the unnamed source's units have none.
306
+ */
307
+ product?: string;
308
+ /** Output prefix inside the site, e.g. "en/3.31", "calico/3.31", or "" when no axis is active. */
279
309
  prefix: string;
280
310
  }
311
+ /**
312
+ * The prefix of a unit's product and locale, without its version: where the unit's
313
+ * versions sit side by side ("calico/en", "en", or ""). A version served at the root
314
+ * (`path: ""`) has this as its prefix.
315
+ */
316
+ export declare function groupPrefix(u: Pick<BuildUnit, "product" | "locale">): string;
317
+ /** Whether two units are versions or languages of the same product (or both of no product). */
318
+ export declare const sameProduct: (a: Pick<BuildUnit, "product">, b: Pick<BuildUnit, "product">) => boolean;
281
319
  /**
282
320
  * Expand sources into units. An axis is "active" (adds a URL segment)
283
321
  * when the sources carry two or more distinct values for it, or when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/ssg",
3
- "version": "0.2.0-beta.144",
3
+ "version": "0.2.0-beta.146",
4
4
  "type": "module",
5
5
  "description": "Documentation static site generator for Dogsbay-MD that builds inside Cloudflare Workers and publishes sites that work offline",
6
6
  "license": "Apache-2.0",
@@ -49,38 +49,38 @@
49
49
  "tailwindcss": "^4.3.2"
50
50
  },
51
51
  "peerDependencies": {
52
- "@dogsbay/elements": "0.2.0-beta.144",
53
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.144",
54
- "@dogsbay/format-openapi": "0.2.0-beta.144",
55
- "@dogsbay/format-topicmap": "0.2.0-beta.144",
56
- "@dogsbay/highlight": "0.2.0-beta.144",
57
- "@dogsbay/minja": "0.2.0-beta.144",
58
- "@dogsbay/primitives": "0.2.0-beta.144",
59
- "@dogsbay/render-html": "0.2.0-beta.144",
60
- "@dogsbay/search": "0.2.0-beta.144",
61
- "@dogsbay/serialize-core": "0.2.0-beta.144",
62
- "@dogsbay/site-check": "0.2.0-beta.144",
63
- "@dogsbay/site-files": "0.2.0-beta.144",
64
- "@dogsbay/types": "0.2.0-beta.144"
52
+ "@dogsbay/elements": "0.2.0-beta.146",
53
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.146",
54
+ "@dogsbay/format-openapi": "0.2.0-beta.146",
55
+ "@dogsbay/format-topicmap": "0.2.0-beta.146",
56
+ "@dogsbay/highlight": "0.2.0-beta.146",
57
+ "@dogsbay/minja": "0.2.0-beta.146",
58
+ "@dogsbay/primitives": "0.2.0-beta.146",
59
+ "@dogsbay/render-html": "0.2.0-beta.146",
60
+ "@dogsbay/search": "0.2.0-beta.146",
61
+ "@dogsbay/serialize-core": "0.2.0-beta.146",
62
+ "@dogsbay/site-check": "0.2.0-beta.146",
63
+ "@dogsbay/site-files": "0.2.0-beta.146",
64
+ "@dogsbay/types": "0.2.0-beta.146"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@axe-core/playwright": "^4.12.1",
68
68
  "@cloudflare/workers-types": "^5.20260921.1",
69
- "@dogsbay/elements": "0.2.0-beta.144",
70
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.144",
71
- "@dogsbay/format-openapi": "0.2.0-beta.144",
72
- "@dogsbay/format-topicmap": "0.2.0-beta.144",
73
- "@dogsbay/highlight": "0.2.0-beta.144",
74
- "@dogsbay/icons": "0.2.0-beta.144",
75
- "@dogsbay/minja": "0.2.0-beta.144",
76
- "@dogsbay/primitives": "0.2.0-beta.144",
77
- "@dogsbay/render-html": "0.2.0-beta.144",
78
- "@dogsbay/search": "0.2.0-beta.144",
79
- "@dogsbay/serialize-core": "0.2.0-beta.144",
80
- "@dogsbay/site-check": "0.2.0-beta.144",
81
- "@dogsbay/site-files": "0.2.0-beta.144",
82
- "@dogsbay/types": "0.2.0-beta.144",
83
- "@dogsbay/ui": "0.2.0-beta.144",
69
+ "@dogsbay/elements": "0.2.0-beta.146",
70
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.146",
71
+ "@dogsbay/format-openapi": "0.2.0-beta.146",
72
+ "@dogsbay/format-topicmap": "0.2.0-beta.146",
73
+ "@dogsbay/highlight": "0.2.0-beta.146",
74
+ "@dogsbay/icons": "0.2.0-beta.146",
75
+ "@dogsbay/minja": "0.2.0-beta.146",
76
+ "@dogsbay/primitives": "0.2.0-beta.146",
77
+ "@dogsbay/render-html": "0.2.0-beta.146",
78
+ "@dogsbay/search": "0.2.0-beta.146",
79
+ "@dogsbay/serialize-core": "0.2.0-beta.146",
80
+ "@dogsbay/site-check": "0.2.0-beta.146",
81
+ "@dogsbay/site-files": "0.2.0-beta.146",
82
+ "@dogsbay/types": "0.2.0-beta.146",
83
+ "@dogsbay/ui": "0.2.0-beta.146",
84
84
  "@mermanjs/web": "0.7.0",
85
85
  "@playwright/test": "1.61.1",
86
86
  "@types/node": "^22.10.0",