@dogsbay/format-astro 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.
@@ -7,7 +7,7 @@
7
7
  * plans/canonical-versions.md, step 2.
8
8
  */
9
9
  import type { ExportPage } from "@dogsbay/types";
10
- import { type VersionSeo } from "@dogsbay/site-files";
10
+ import { type ProductModel, type VersionSeo } from "@dogsbay/site-files";
11
11
  export interface CanonicalPlanOptions {
12
12
  /** Pages the build does not emit (fragments, `excludeFromRoutes`): never a canonical target. */
13
13
  isEmitted?: (page: ExportPage) => boolean;
@@ -18,6 +18,8 @@ export interface CanonicalPlanOptions {
18
18
  id: string;
19
19
  } & VersionSeo>;
20
20
  defaultVersion?: string;
21
+ /** With a `products:` block: each product's own versions and default replace `versions`/`defaultVersion`. */
22
+ products?: ProductModel;
21
23
  redirects?: Record<string, string | {
22
24
  to: string;
23
25
  status?: number;
package/dist/canonical.js CHANGED
@@ -1,4 +1,4 @@
1
- import { decideCanonical } from "@dogsbay/site-files";
1
+ import { decideCanonical, productDefault } from "@dogsbay/site-files";
2
2
  import { normalizePrefix, originOf, pageHref } from "./sitemap.js";
3
3
  import { inPageUnit, pageUnitPrefix } from "./redirects.js";
4
4
  const URL_RE = /^[a-z][a-z0-9+.-]*:/i;
@@ -60,27 +60,31 @@ export function canonicalPlan(pages, o) {
60
60
  return s;
61
61
  return s.endsWith("/" + inV) ? s.slice(0, -inV.length - 1) : s === inV ? "" : s;
62
62
  };
63
+ // The page's product's versions and default (the site-wide ones without a products: block).
64
+ const axisOf = (p) => o.products ? productDefault(o.products, p.multiSource?.namespace) ?? {} : { versions: o.versions, defaultVersion: o.defaultVersion };
63
65
  const defaults = new Map();
64
- if (o.defaultVersion) {
66
+ {
65
67
  for (const p of pages) {
66
- if (p.multiSource?.version !== o.defaultVersion || !live(p))
68
+ const dv = axisOf(p).defaultVersion;
69
+ if (!dv || p.multiSource?.version !== dv || !live(p))
67
70
  continue;
68
71
  const g = defaults.get(group(p)) ?? { prefix: prefixOf(p), pages: new Map() };
69
72
  g.pages.set(inVersion(p), slugOf(p));
70
73
  defaults.set(group(p), g);
71
74
  }
72
75
  }
73
- const hasOlder = pages.some((p) => p.multiSource?.version && p.multiSource.version !== o.defaultVersion);
76
+ const hasOlder = pages.some((p) => p.multiSource?.version && p.multiSource.version !== axisOf(p).defaultVersion);
74
77
  const url = (slug) => (origin ? `${origin}${pageHref(slug, prefix)}` : undefined);
75
78
  for (const p of pages) {
76
79
  if (!live(p))
77
80
  continue;
78
- const version = o.versions?.find((v) => v.id === p.multiSource?.version);
81
+ const axis = axisOf(p);
82
+ const version = axis.versions?.find((v) => v.id === p.multiSource?.version);
79
83
  const table = defaults.get(group(p));
80
84
  const fm = typeof p.frontmatter?.canonicalUrl === "string" ? p.frontmatter.canonicalUrl : undefined;
81
85
  const d = decideCanonical({
82
86
  selfUrl: url(slugOf(p)),
83
- isDefault: !hasOlder || !p.multiSource?.version || p.multiSource.version === o.defaultVersion || !table,
87
+ isDefault: !hasOlder || !p.multiSource?.version || p.multiSource.version === axis.defaultVersion || !table,
84
88
  path: inVersion(p),
85
89
  version,
86
90
  frontmatter: fm,
package/dist/index.d.ts CHANGED
@@ -31,5 +31,6 @@ export type { DiffMarks } from "./diff-decoration.js";
31
31
  */
32
32
  export { BOOK_SEGMENT, assembleBook, bookHeading, bookSlugFrom, estimateTreeBytes, openAsPageNode, rewriteIntraBookLinks, shiftHeadings, slugFromHref, } from "./granularity.js";
33
33
  export type { AssembleBookOptions, BookView } from "./granularity.js";
34
- export { pageUnitPrefix, inPageUnit, normalizeTarget } from "./redirects.js";
34
+ export { pageUnitPrefix, inPageUnit, normalizeTarget, collectRedirects } from "./redirects.js";
35
+ export { canonicalPlan } from "./canonical.js";
35
36
  export { TONES, CARD_TONE_CLASSES, TAB_VARIANTS, THEME_SCOPES } from "./tone-classes.js";
package/dist/index.js CHANGED
@@ -59,6 +59,7 @@ export { DIFF_CSS, decorateDiff, diffMarksOf, hasDiffMarks, injectAttrs, inlineS
59
59
  * See plans/granularity-views.md and docs/single-page-view.md.
60
60
  */
61
61
  export { BOOK_SEGMENT, assembleBook, bookHeading, bookSlugFrom, estimateTreeBytes, openAsPageNode, rewriteIntraBookLinks, shiftHeadings, slugFromHref, } from "./granularity.js";
62
- export { pageUnitPrefix, inPageUnit, normalizeTarget } from "./redirects.js";
62
+ export { pageUnitPrefix, inPageUnit, normalizeTarget, collectRedirects } from "./redirects.js";
63
+ export { canonicalPlan } from "./canonical.js";
63
64
  // Variants, tones and scoped themes: the class tables both renderers draw from
64
65
  export { TONES, CARD_TONE_CLASSES, TAB_VARIANTS, THEME_SCOPES } from "./tone-classes.js";
package/dist/project.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExportPage, NavItem } from "@dogsbay/types";
2
+ import { type ProductModel } from "@dogsbay/site-files";
2
3
  import { type BlogEmitConfig } from "./blog.js";
3
4
  import { type RedirectsConfig } from "./redirects.js";
4
5
  /**
@@ -197,6 +198,24 @@ export interface AstroProjectOptions {
197
198
  * default (enforced at config load).
198
199
  */
199
200
  defaultVersion?: string;
201
+ /**
202
+ * With a `products:` block: the model from @dogsbay/site-files
203
+ * (`productModel`). Each product's own versions and default then drive
204
+ * canonicals, the version switcher (`productVersions`), the forward from
205
+ * a product's root to its default version, and the data files
206
+ * `_dogsbay/versions.js` and `<unit home>_dogsbay/pages.js`.
207
+ */
208
+ products?: ProductModel;
209
+ /**
210
+ * The project's own `_headers` / `_redirects` (from the root of a source served at the site
211
+ * root): written first, the generated rules after, generated ones they already decide left out
212
+ * (@dogsbay/site-files host-files.ts, as dogsbay-ssg does). With its own `_redirects` the file is
213
+ * written whatever `deploy` says, since the project's host reads it.
214
+ */
215
+ hostFiles?: {
216
+ headers?: string;
217
+ redirects?: string;
218
+ };
200
219
  /**
201
220
  * Declared locales for the i18n axis. Same role as
202
221
  * `versions` for the locale axis.
@@ -601,6 +620,20 @@ export interface SwitcherMap {
601
620
  * the full URL plus the (locale, version) combo it represents.
602
621
  */
603
622
  byLogicalKey: Record<string, SwitcherVariant[]>;
623
+ /**
624
+ * With a `products:` block: each product's version rows per locale, keyed
625
+ * `<product|_>/<locale|_>`, in config order, hidden versions left out, each
626
+ * with its home URL (where the row goes when the page is not in it). The
627
+ * version switcher uses these instead of `versions`.
628
+ */
629
+ productVersions?: Record<string, Array<{
630
+ id: string;
631
+ label?: string;
632
+ eol?: boolean;
633
+ prerelease?: boolean;
634
+ default?: boolean;
635
+ home: string;
636
+ }>>;
604
637
  }
605
638
  export interface SwitcherVariant {
606
639
  /** Present when the locale axis is active for this site. */
package/dist/project.js CHANGED
@@ -30,6 +30,7 @@ import { buildSitemapFiles, buildSitemapIndex } from "./sitemap.js";
30
30
  import { canonicalPlan } from "./canonical.js";
31
31
  import { headMetadata } from "./head-metadata.js";
32
32
  import { buildHeadersFile, buildRobotsTxt as buildRobotsTxtFile } from "@dogsbay/site-files";
33
+ import { mergeHeaders, mergeRedirects, productRootRedirects, siteFiles, siteRootTarget, switcher as productSwitcherRows, toPagesScript, toScript, unitKeyString, unitManifest, unitOf, } from "@dogsbay/site-files";
33
34
  import { assembleBook, estimateTreeBytes } from "./granularity.js";
34
35
  /**
35
36
  * The per-topic "Read as single page" affordance (granularity book view).
@@ -1776,6 +1777,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1776
1777
  basePath: combined,
1777
1778
  versions: options.versions,
1778
1779
  defaultVersion: options.defaultVersion,
1780
+ products: options.products,
1779
1781
  redirects: options.redirects,
1780
1782
  });
1781
1783
  // hreflang alternates, WebSite on home pages, BreadcrumbList from the nav (src/head-metadata.ts).
@@ -2348,13 +2350,16 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2348
2350
  // With the default version at the root (`path: ""`) and no locale or
2349
2351
  // namespace, it has no subtree: its entries are the ones outside every
2350
2352
  // other version's subtree.
2351
- const firstHref = (defaultVersionPrefixes.length > 0
2352
- ? findFirstNavHrefUnder(outputNav, defaultVersionPrefixes, [...subtreeRoots]
2353
- .filter(([q]) => defaultVersionPrefixes.some((d) => `${combined}/${q}`.startsWith(`${d}/`)))
2354
- .map(([q]) => `${combined}/${q}`))
2355
- : rootVersion && subtreeRoots.size > 0
2356
- ? findFirstNavHrefUnder(outputNav, [combined], nestedIn(""))
2357
- : undefined) ?? findFirstNavHref(outputNav, basePath);
2353
+ // With products and no unnamed source, `/` goes to the first product's default home (site-files siteRootTarget).
2354
+ const productRoot = options.products ? siteRootTarget(options.products) : undefined;
2355
+ const firstHref = productRoot !== undefined ? `${combined}${productRoot}` :
2356
+ (defaultVersionPrefixes.length > 0
2357
+ ? findFirstNavHrefUnder(outputNav, defaultVersionPrefixes, [...subtreeRoots]
2358
+ .filter(([q]) => defaultVersionPrefixes.some((d) => `${combined}/${q}`.startsWith(`${d}/`)))
2359
+ .map(([q]) => `${combined}/${q}`))
2360
+ : rootVersion && subtreeRoots.size > 0
2361
+ ? findFirstNavHrefUnder(outputNav, [combined], nestedIn(""))
2362
+ : undefined) ?? findFirstNavHref(outputNav, basePath);
2358
2363
  const needsRootRedirect = basePath !== "" ||
2359
2364
  (!section && !rootIndexEmitted && firstHref !== "");
2360
2365
  if (needsRootRedirect) {
@@ -2457,7 +2462,10 @@ function emitRedirects(outputDir, options, pages) {
2457
2462
  // Front matter paths are relative to the page's language and version (#096): collectRedirects needs its original slug.
2458
2463
  multiSource: p.multiSource,
2459
2464
  })),
2460
- config: options.redirects,
2465
+ // A product's root forwards to its default version's home; a redirect the config gives for it wins.
2466
+ config: options.products
2467
+ ? { ...Object.fromEntries(productRootRedirects(options.products)), ...options.redirects }
2468
+ : options.redirects,
2461
2469
  prefix,
2462
2470
  });
2463
2471
  if (errors.length > 0) {
@@ -2465,9 +2473,21 @@ function emitRedirects(outputDir, options, pages) {
2465
2473
  }
2466
2474
  for (const w of warnings)
2467
2475
  console.warn(` Warning: ${w}`);
2476
+ const publicDir = join(outputDir, "public");
2477
+ const own = options.hostFiles?.redirects;
2478
+ if (own !== undefined) {
2479
+ // The project's _redirects first, ours after: written whatever the deploy target, since their host reads it
2480
+ const merged = mergeRedirects(own, rules.length ? renderRedirectsFile(rules) : undefined, options.deploy === "cloudflare-workers");
2481
+ mkdirSync(publicDir, { recursive: true });
2482
+ writeFileSync(join(publicDir, "_redirects"), merged.text);
2483
+ for (const n of merged.notes)
2484
+ console.warn(` Warning: ${n}`);
2485
+ console.log(` Redirects: the project's _redirects, then the generated rules it does not already decide → public/_redirects`);
2486
+ if (options.deploy === "cloudflare-workers")
2487
+ return;
2488
+ }
2468
2489
  if (rules.length === 0)
2469
2490
  return;
2470
- const publicDir = join(outputDir, "public");
2471
2491
  if (options.deploy === "cloudflare-workers") {
2472
2492
  // Workers Static Assets reads _redirects from the ROOT of the assets
2473
2493
  // directory. On a subpath mount `public/` lands at dist/<mount>/, so the
@@ -2572,7 +2592,7 @@ export function emitSwitcherMap(pages, outputDir, options) {
2572
2592
  }
2573
2593
  const versionAxisActive = versionsSeen.size >= 2;
2574
2594
  const localeAxisActive = localesSeen.size >= 2;
2575
- if (!versionAxisActive && !localeAxisActive) {
2595
+ if (!versionAxisActive && !localeAxisActive && !options.products) {
2576
2596
  mkdirSync(dataDir, { recursive: true });
2577
2597
  writeFileSync(outPath, JSON.stringify({ versions: [], locales: [], byLogicalKey: {} }, null, 2));
2578
2598
  return;
@@ -2601,9 +2621,73 @@ export function emitSwitcherMap(pages, outputDir, options) {
2601
2621
  byLogicalKey[key].push(variant);
2602
2622
  }
2603
2623
  const data = { versions, locales, byLogicalKey };
2624
+ if (options.products) {
2625
+ data.productVersions = productVersionRows(options.products, combined);
2626
+ emitProductDataFiles(pages, outputDir, options, options.products);
2627
+ }
2604
2628
  mkdirSync(dataDir, { recursive: true });
2605
2629
  writeFileSync(outPath, JSON.stringify(data, null, 2));
2606
2630
  }
2631
+ /** Each product's switcher rows per locale (`<product|_>/<locale|_>`), with full home URLs. */
2632
+ function productVersionRows(model, combined) {
2633
+ const out = {};
2634
+ const locales = model.localeAxis ? [...new Set(model.units.map((u) => u.locale))] : [undefined];
2635
+ for (const p of model.products) {
2636
+ if (!p.versions.length)
2637
+ continue;
2638
+ for (const l of locales) {
2639
+ const rows = productSwitcherRows(model, p.name, l);
2640
+ if (!rows.length)
2641
+ continue;
2642
+ out[`${p.name ?? "_"}/${l ?? "_"}`] = rows.map((r) => ({
2643
+ id: r.id,
2644
+ label: r.label,
2645
+ ...(r.eol ? { eol: true } : {}),
2646
+ ...(r.prerelease ? { prerelease: true } : {}),
2647
+ ...(r.isDefault ? { default: true } : {}),
2648
+ home: `${combined}${r.home}`,
2649
+ }));
2650
+ }
2651
+ }
2652
+ return out;
2653
+ }
2654
+ /**
2655
+ * The data files dogsbay-ssg writes too (@dogsbay/site-files products.ts):
2656
+ * `_dogsbay/versions.js` (+ `.json`), the switchers and product switcher every
2657
+ * page can load, and each unit's page list in its own folder,
2658
+ * `<home>_dogsbay/pages.js`. Scripts, so they also work from `file://`.
2659
+ */
2660
+ function emitProductDataFiles(pages, outputDir, options, model) {
2661
+ const publicDir = join(outputDir, "public", ...basePathSegments(normalizeBasePath(options.basePath)));
2662
+ const byUnit = new Map();
2663
+ for (const page of pages) {
2664
+ const ms = page.multiSource;
2665
+ if (!ms || page.redirect || page.fragment === true)
2666
+ continue;
2667
+ const key = unitKeyString({ ...(ms.namespace ? { product: ms.namespace } : {}), ...(ms.locale ? { locale: ms.locale } : {}), ...(ms.version ? { version: ms.version } : {}) });
2668
+ const list = byUnit.get(key) ?? [];
2669
+ list.push(ms.originalSlug.replace(/^\/+|\/+$/g, "").replace(/(^|\/)index$/, ""));
2670
+ byUnit.set(key, list);
2671
+ }
2672
+ const built = new Set(model.units.map(unitKeyString));
2673
+ const manifests = [...byUnit.entries()]
2674
+ .filter(([key]) => built.has(key))
2675
+ .map(([key, list]) => {
2676
+ const [product, locale, version] = key.split("/").map((x) => (x === "_" ? undefined : x));
2677
+ return unitManifest({ ...(product ? { product } : {}), ...(locale ? { locale } : {}), ...(version ? { version } : {}) }, { pages: [...new Set(list)].sort(), sitemap: [], redirects: [] });
2678
+ });
2679
+ const files = siteFiles(model, manifests);
2680
+ const dataDir = join(publicDir, "_dogsbay");
2681
+ mkdirSync(dataDir, { recursive: true });
2682
+ writeFileSync(join(dataDir, "versions.js"), toScript("__DOGSBAY_VERSIONS__", files.versions));
2683
+ writeFileSync(join(dataDir, "versions.json"), JSON.stringify(files.versions));
2684
+ for (const m of manifests) {
2685
+ const unit = unitOf(model, { ...(m.product ? { product: m.product } : {}), ...(m.locale ? { locale: m.locale } : {}), ...(m.version ? { version: m.version } : {}) });
2686
+ const dest = join(publicDir, ...unit.segments, "_dogsbay", "pages.js");
2687
+ mkdirSync(dirname(dest), { recursive: true });
2688
+ writeFileSync(dest, toPagesScript(unit.home, m.pages));
2689
+ }
2690
+ }
2607
2691
  /**
2608
2692
  * Compose a switcherMap axis header from declared entries +
2609
2693
  * the set of ids actually seen in pages. Honours declared
@@ -2744,6 +2828,15 @@ function buildMissingTranslationStub(defaultUrl, fromLocale, toLocale) {
2744
2828
  * `emitAstroPages` (they share the page-loop's serialization step).
2745
2829
  */
2746
2830
  export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, options) {
2831
+ // The project's own _headers first, the generated Link header after (when llms.txt is on)
2832
+ const ownHeaders = options.hostFiles?.headers;
2833
+ if (ownHeaders !== undefined && options.llmsTxt === false) {
2834
+ mkdirSync(join(outputDir, "public"), { recursive: true });
2835
+ const merged = mergeHeaders(ownHeaders, undefined, options.deploy === "cloudflare-workers");
2836
+ writeFileSync(join(outputDir, "public", "_headers"), merged.text);
2837
+ for (const n of merged.notes)
2838
+ console.warn(` Warning: ${n}`);
2839
+ }
2747
2840
  if (options.llmsTxt !== false) {
2748
2841
  emitLlmsTxtFiles(outputDir, siteName, options, outputNav, pages);
2749
2842
  // public/_headers — Cloudflare Workers / Pages convention. Adds an
@@ -2753,7 +2846,16 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
2753
2846
  mkdirSync(join(outputDir, "public"), { recursive: true });
2754
2847
  // _headers Link header points at the per-mount llms.txt at
2755
2848
  // <combined>/llms.txt — the URL agents would actually fetch.
2756
- writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile(withBasePath(combinedPrefix(options), "/llms.txt")));
2849
+ const generated = buildHeadersFile(withBasePath(combinedPrefix(options), "/llms.txt"));
2850
+ if (ownHeaders === undefined) {
2851
+ writeFileSync(join(outputDir, "public", "_headers"), generated);
2852
+ }
2853
+ else {
2854
+ const merged = mergeHeaders(ownHeaders, generated, options.deploy === "cloudflare-workers");
2855
+ writeFileSync(join(outputDir, "public", "_headers"), merged.text);
2856
+ for (const n of merged.notes)
2857
+ console.warn(` Warning: ${n}`);
2858
+ }
2757
2859
  }
2758
2860
  // Sitemap — emitted by Dogsbay (not @astrojs/sitemap) into
2759
2861
  // public/<basePath>/sitemap-{index,0}.xml so multi-mount deploys
@@ -3163,6 +3265,7 @@ function emitSitemapFiles(outputDir, options, pages) {
3163
3265
  basePath: combined,
3164
3266
  versions: options.versions,
3165
3267
  defaultVersion: options.defaultVersion,
3268
+ products: options.products,
3166
3269
  redirects: options.redirects,
3167
3270
  });
3168
3271
  // More than one file past 50,000 URLs, the protocol's limit per file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/format-astro",
3
- "version": "0.2.0-beta.144",
3
+ "version": "0.2.0-beta.146",
4
4
  "description": "Astro format plugin for Dogsbay — export TreeNode[] to Astro component markup",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,12 +32,12 @@
32
32
  "license": "MIT",
33
33
  "dependencies": {
34
34
  "markdown-it": "^14.3.0",
35
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.144",
36
- "@dogsbay/render-html": "0.2.0-beta.144",
37
- "@dogsbay/serialize-core": "0.2.0-beta.144",
38
- "@dogsbay/site-files": "0.2.0-beta.144",
39
- "@dogsbay/types": "0.2.0-beta.144",
40
- "@dogsbay/ui": "0.2.0-beta.144"
35
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.146",
36
+ "@dogsbay/render-html": "0.2.0-beta.146",
37
+ "@dogsbay/serialize-core": "0.2.0-beta.146",
38
+ "@dogsbay/site-files": "0.2.0-beta.146",
39
+ "@dogsbay/types": "0.2.0-beta.146",
40
+ "@dogsbay/ui": "0.2.0-beta.146"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",