@takazudo/zudo-doc 5.15.0 → 5.16.0

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/asset-index-page/index.js +1 -1
  3. package/dist/asset-page/index.js +1 -1
  4. package/dist/config.d.ts +15 -6
  5. package/dist/config.js +2 -0
  6. package/dist/doc-content-header/index.js +1 -1
  7. package/dist/doc-page-shell/index.js +1 -1
  8. package/dist/home-page/index.js +1 -1
  9. package/dist/nav-indexing/versions-page-content.js +1 -1
  10. package/dist/plugins/internal/asset-viewer/asset-pages.d.ts +53 -0
  11. package/dist/plugins/internal/asset-viewer/asset-pages.js +98 -0
  12. package/dist/plugins/internal/asset-viewer/scan.d.ts +18 -1
  13. package/dist/plugins/internal/asset-viewer/scan.js +34 -27
  14. package/dist/plugins/internal/llms-txt/assets.d.ts +24 -0
  15. package/dist/plugins/internal/llms-txt/assets.js +65 -0
  16. package/dist/plugins/internal/llms-txt/dev-middleware.d.ts +5 -0
  17. package/dist/plugins/internal/llms-txt/dev-middleware.js +9 -1
  18. package/dist/plugins/internal/llms-txt/emit.js +21 -5
  19. package/dist/plugins/internal/llms-txt/generate.d.ts +3 -3
  20. package/dist/plugins/internal/llms-txt/generate.js +55 -2
  21. package/dist/plugins/internal/llms-txt/index.d.ts +4 -1
  22. package/dist/plugins/internal/llms-txt/index.js +4 -0
  23. package/dist/plugins/internal/llms-txt/types.d.ts +24 -0
  24. package/dist/plugins/internal/llms-txt/types.js +4 -0
  25. package/dist/plugins/internal/search-index/collect.js +46 -1
  26. package/dist/plugins/internal/search-index/types.d.ts +6 -1
  27. package/dist/plugins/llms-txt.js +9 -1
  28. package/dist/plugins/search-index.js +10 -1
  29. package/dist/preset.d.ts +5 -1
  30. package/dist/preset.js +15 -2
  31. package/dist/route-enumerators/index.d.ts +14 -1
  32. package/dist/route-enumerators/index.js +38 -0
  33. package/dist/safelist.css +1 -1
  34. package/dist/settings.d.ts +15 -0
  35. package/dist/tag-pages/index.js +2 -2
  36. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to `@takazudo/zudo-doc` are documented in this file.
4
4
 
5
5
  The format is based on Keep a Changelog, and release notes are generated from the changelog MDX pages.
6
6
 
7
+ ## [5.16.0] - 2026-09-02
8
+
9
+ ### Features
10
+
11
+ - Added opt-in Markdown/MDX transclusion through `transclude`, plus independent `assetViewerIndexing` controls for search, llms.txt, and sitemap output. (`8145aae41`)
12
+ - Generated asset-viewer pages can now be enumerated in sitemaps and indexed in search and llms outputs, with locale, base-path, exclusion, and bounded content-extraction handling. (`5da03612e`, `b48009d7e`, `3de313585`, `76b90bc48`)
13
+
14
+ ### Bug Fixes
15
+
16
+ - Long unbroken titles now wrap across documentation, home, asset, version, and tag pages. (`f21c6b73d`)
17
+ - Asset search excerpts now use bounded reads for large files. (`6c81a13b3`)
18
+ - Asset links in llms outputs now honor the configured `siteUrl`. (`67482a1d5`)
19
+
20
+ ### Other Changes
21
+
22
+ - Raised the optional `@takazudo/zudo-doc-history-server` peer floor to the already-published 5.15.0 release. (`01bd83bc7`)
23
+
7
24
  ## [5.15.0] - 2026-09-01
8
25
 
9
26
  ### Features
@@ -145,7 +145,7 @@ function createAssetIndexPageView(ctx) {
145
145
  /* @__PURE__ */ jsx("span", { class: "rounded-full border border-muted px-hsp-sm py-vsp-3xs text-fg", children: title }),
146
146
  /* @__PURE__ */ jsx("span", { class: "rounded-full border border-muted px-hsp-sm py-vsp-3xs text-muted", children: t("asset.indexBadge", locale) })
147
147
  ] }),
148
- /* @__PURE__ */ jsx("h1", { class: "mb-vsp-xs border-b border-fg pb-vsp-xs font-mono text-heading font-bold leading-tight", children: title }),
148
+ /* @__PURE__ */ jsx("h1", { class: "mb-vsp-xs border-b border-fg pb-vsp-xs font-mono text-heading font-bold leading-tight break-words", children: title }),
149
149
  /* @__PURE__ */ jsxs("div", { "data-doc-metainfo": true, class: "mb-vsp-md flex flex-wrap items-center gap-x-hsp-md gap-y-vsp-2xs text-caption text-fg", children: [
150
150
  /* @__PURE__ */ jsx("span", { children: countLabel(tree.fileCount, fileCountLabel, fileCountSingleLabel) }),
151
151
  /* @__PURE__ */ jsx("span", { children: countLabel(folders, folderCountLabel, folderCountSingleLabel) }),
@@ -42,7 +42,7 @@ function AssetHeader({ asset, locale, badge, updatedLabel, linesLabel }) {
42
42
  const facet = facetLabel(asset, linesLabel);
43
43
  return /* @__PURE__ */ jsxs("header", { children: [
44
44
  /* @__PURE__ */ jsx(AssetEyebrow, { asset, badge }),
45
- /* @__PURE__ */ jsx("h1", { class: "mb-vsp-xs border-b border-fg pb-vsp-xs font-mono text-heading font-bold leading-tight", children: asset.name }),
45
+ /* @__PURE__ */ jsx("h1", { class: "mb-vsp-xs border-b border-fg pb-vsp-xs font-mono text-heading font-bold leading-tight break-words", children: asset.name }),
46
46
  /* @__PURE__ */ jsxs("div", { "data-doc-metainfo": true, class: "mb-vsp-md flex flex-wrap items-center gap-x-hsp-md gap-y-vsp-2xs text-caption text-fg", children: [
47
47
  asset.dir && /* @__PURE__ */ jsx("span", { children: asset.dir }),
48
48
  facet && /* @__PURE__ */ jsx("span", { children: facet }),
package/dist/config.d.ts CHANGED
@@ -18,11 +18,13 @@
18
18
  * `zudoDoc()` SHALLOW-merges the user's fields over {@link DEFAULT_SETTINGS} —
19
19
  * top-level fields only (`{ ...DEFAULT_SETTINGS, ...user }`). A supplied nested
20
20
  * object (e.g. `colorMode`, `metaTags`) REPLACES the default wholesale; it is
21
- * NOT deep-merged key-by-key. This is safe because every nested config type is
22
- * all-required-fields, so a caller supplying one supplies all of its fields.
23
- * ONE exception: `FaviconConfig` is all-optional wholesale replacement IS its
24
- * semantics (only the supplied slots emit a `<link rel="icon">`), and its
25
- * default is `undefined`, so there is nothing for a partial object to clobber.
21
+ * NOT deep-merged key-by-key. Nested config types that intentionally expose
22
+ * optional keys (such as `FrontmatterPreviewConfig` and
23
+ * `AssetViewerIndexingConfig`) get their per-key defaults at the read site;
24
+ * this shallow merge never fills nested keys. `FaviconConfig` is also
25
+ * all-optional wholesale replacement IS its semantics (only the supplied
26
+ * slots emit a `<link rel="icon">`), and its default is `undefined`, so there
27
+ * is nothing for a partial object to clobber.
26
28
  * `zudoDoc()` also supplies the Wave-3 package defaults
27
29
  * (`buildDocsSchema`/`directiveVocabulary`/`translations`/`colorSchemes`/tag
28
30
  * vocabulary) unless overridden, and returns a **complete `ZfbConfig`** — the
@@ -65,7 +67,7 @@ import type { ZfbConfig, BundleConfig } from "@takazudo/zfb/config";
65
67
  import type { ZodType } from "zod";
66
68
  import type { DirectiveVocabulary, PresetTranslations, PresetTagVocabularyEntry } from "./preset.js";
67
69
  import type { ColorScheme } from "./color-scheme-utils.js";
68
- import type { Settings, ColorModeConfig, LocaleConfig, MetaTagsConfig, SiteHeadConfig, TagPlacement, TagGovernanceMode, VersionConfig, FooterConfig, HeaderNavItem, HeaderRightItem, HomeConfig, FrontmatterPreviewConfig, BodyFootUtilAreaConfig, HtmlPreviewConfig, FaviconConfig } from "./settings.js";
70
+ import type { Settings, ColorModeConfig, LocaleConfig, MetaTagsConfig, SiteHeadConfig, TagPlacement, TagGovernanceMode, VersionConfig, FooterConfig, HeaderNavItem, HeaderRightItem, HomeConfig, AssetViewerIndexingConfig, FrontmatterPreviewConfig, BodyFootUtilAreaConfig, HtmlPreviewConfig, FaviconConfig } from "./settings.js";
69
71
  /** The `settings.claudeResources` block (or `false` when disabled). */
70
72
  type ClaudeResourcesConfig = {
71
73
  claudeDir: string;
@@ -188,6 +190,11 @@ export interface ZudoDocConfig {
188
190
  * @default true
189
191
  */
190
192
  mermaid?: boolean;
193
+ /**
194
+ * Enable transclusion of other Markdown/MDX files via `:::include`.
195
+ * @default false
196
+ */
197
+ transclude?: boolean;
191
198
  /**
192
199
  * Add `noindex,nofollow` to every page (for internal docs).
193
200
  * @default false
@@ -377,6 +384,8 @@ export interface ZudoDocConfig {
377
384
  assetViewerExclude?: string[];
378
385
  /** When `true` (and `assetViewer` is `true`), generate a listing page at `/<assetViewerRoutePrefix>/` showing every managed asset as a folder tree. No effect when `assetViewer` is `false`. @default false */
379
386
  assetViewerIndex?: boolean;
387
+ /** Per-output opt-in controls for indexing generated asset-viewer pages. Omitted keys are off; `false` disables all asset-page indexing. @default false */
388
+ assetViewerIndexing?: AssetViewerIndexingConfig | false;
380
389
  /**
381
390
  * Body-foot utility area (doc-history / view-source), or `false` to disable.
382
391
  * @default false
package/dist/config.js CHANGED
@@ -53,6 +53,7 @@ const DEFAULT_SETTINGS = {
53
53
  defaultLocale: "en",
54
54
  locales: {},
55
55
  mermaid: true,
56
+ transclude: false,
56
57
  noindex: false,
57
58
  editUrl: false,
58
59
  githubUrl: false,
@@ -98,6 +99,7 @@ const DEFAULT_SETTINGS = {
98
99
  assetViewerRoutePrefix: "files",
99
100
  assetViewerExclude: [],
100
101
  assetViewerIndex: false,
102
+ assetViewerIndexing: false,
101
103
  bodyFootUtilArea: false,
102
104
  htmlPreview: void 0,
103
105
  versions: false,
@@ -20,7 +20,7 @@ function createDocContentHeader(ctx) {
20
20
  version
21
21
  }) {
22
22
  return /* @__PURE__ */ jsxs(Fragment, { children: [
23
- /* @__PURE__ */ jsx("h1", { class: "text-heading font-bold border-b border-fg pb-vsp-xs mb-vsp-xs", children: entry.data.title }),
23
+ /* @__PURE__ */ jsx("h1", { class: "text-heading font-bold border-b border-fg pb-vsp-xs mb-vsp-xs break-words", children: entry.data.title }),
24
24
  docContentHeaderExtras?.({
25
25
  entry,
26
26
  slug,
@@ -176,7 +176,7 @@ function createDocPageShell(ctx) {
176
176
  Fragment (not <div>) so children become direct children of
177
177
  <article class="zd-content">, picking up the flow-space rule. */
178
178
  /* @__PURE__ */ jsxs(Fragment, { children: [
179
- /* @__PURE__ */ jsx("h1", { class: "text-heading font-bold border-b border-fg pb-vsp-xs mb-vsp-xs", children: autoIndexLabel }),
179
+ /* @__PURE__ */ jsx("h1", { class: "text-heading font-bold border-b border-fg pb-vsp-xs mb-vsp-xs break-words", children: autoIndexLabel }),
180
180
  metainfoSlot,
181
181
  description && /* @__PURE__ */ jsx("p", { class: "mb-vsp-lg text-title text-muted", "data-doc-description": true, children: description }),
182
182
  /* @__PURE__ */ jsx(NavCardGrid, { children: autoIndexChildren ?? [] })
@@ -117,7 +117,7 @@ function createHomePageView(ctx) {
117
117
  }
118
118
  ) : null,
119
119
  /* @__PURE__ */ jsxs("div", { children: [
120
- /* @__PURE__ */ jsx("h1", { class: "text-heading font-bold mb-vsp-2xs", children: settings.siteName }),
120
+ /* @__PURE__ */ jsx("h1", { class: "text-heading font-bold mb-vsp-2xs break-words", children: settings.siteName }),
121
121
  /* @__PURE__ */ jsx("p", { class: "text-muted text-small mb-vsp-sm", children: settings.siteDescription }),
122
122
  /* @__PURE__ */ jsx("div", { class: "flex items-center justify-center lg:justify-start gap-hsp-md text-small", children: rowItems.map((item, index) => /* @__PURE__ */ jsxs(Fragment2, { children: [
123
123
  index > 0 && /* @__PURE__ */ jsx("span", { class: "text-muted", children: "/" }),
@@ -3,7 +3,7 @@ import { ChevronRight } from "../icons/index.js";
3
3
  function VersionsPageContent(props) {
4
4
  const { latestHref, versions, labels } = props;
5
5
  return /* @__PURE__ */ jsxs(Fragment, { children: [
6
- /* @__PURE__ */ jsx("h1", { class: "text-heading font-bold mb-vsp-lg", children: labels.pageTitle }),
6
+ /* @__PURE__ */ jsx("h1", { class: "text-heading font-bold mb-vsp-lg break-words", children: labels.pageTitle }),
7
7
  /* @__PURE__ */ jsxs("section", { class: "mb-vsp-xl", children: [
8
8
  /* @__PURE__ */ jsx("h2", { class: "text-title font-bold mb-vsp-xs", children: labels.latestTitle }),
9
9
  /* @__PURE__ */ jsx("p", { class: "text-small text-muted mb-vsp-sm", children: labels.latestDescription }),
@@ -0,0 +1,53 @@
1
+ import type { AssetViewerIndexingConfig } from "../../../settings.js";
2
+ /** The serialized asset-viewer settings shared by index consumers. */
3
+ export interface AssetScanProjection {
4
+ assetViewer: boolean;
5
+ assetViewerIndexing: AssetViewerIndexingConfig | false;
6
+ assetViewerDir: string;
7
+ assetViewerRoutePrefix: string;
8
+ assetViewerExclude: readonly string[];
9
+ base: string;
10
+ /** Non-default locales, keyed by locale code. */
11
+ locales: Record<string, {
12
+ dir?: string;
13
+ }>;
14
+ /** Route prefixes whose generated pages exist only in the default locale. */
15
+ defaultLocaleOnlyPrefixes: readonly string[];
16
+ }
17
+ /** The asset-page output currently supported by a consumer. */
18
+ export type AssetIndexConsumer = keyof AssetViewerIndexingConfig;
19
+ /** Minimal normalized descriptor consumed by search, llms.txt, and sitemap. */
20
+ export interface AssetPageDescriptor {
21
+ /** NFC-normalized POSIX path relative to `assetViewerDir`. */
22
+ path: string;
23
+ /** Base-prefixed, trailing-slashed generated asset-viewer URL. */
24
+ url: string;
25
+ /** Non-default locale segment; omitted for the default locale. */
26
+ locale?: string;
27
+ /** Whether the asset has a text-like extension understood by the viewer. */
28
+ isText: boolean;
29
+ /** Current byte size of the public asset. */
30
+ size: number;
31
+ }
32
+ export interface AssetPageDescriptorOptions {
33
+ /** Runtime project root injected by the zfb plugin wrapper. */
34
+ projectRoot: string;
35
+ /** Serialized projection from `zudoDocPreset()`. */
36
+ assetScan?: AssetScanProjection;
37
+ /** The output whose explicit indexing gate must be enabled. */
38
+ consumer: AssetIndexConsumer;
39
+ }
40
+ /**
41
+ * Scan the public asset directory and materialize one descriptor per emitted
42
+ * asset page. The default-locale descriptor is emitted first, followed by
43
+ * configured locales in object iteration order. Every descriptor URL is
44
+ * minted by `assetViewerHref()` so encoding, base, and trailing-slash rules
45
+ * cannot diverge between consumers.
46
+ *
47
+ * The descriptor path is intentionally synchronous. Search and llms.txt are
48
+ * synchronous build/dev paths, and `connect-adapter.ts` does not await a
49
+ * middleware's returned promise. Callers must pass the runtime `projectRoot`
50
+ * supplied by the plugin wrapper; it is never part of the serialized preset
51
+ * projection.
52
+ */
53
+ export declare function collectAssetPageDescriptors(options: AssetPageDescriptorOptions): AssetPageDescriptor[];
@@ -0,0 +1,98 @@
1
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
2
+ import { extname, resolve } from "node:path";
3
+ import { assetViewerHref } from "../../../asset-path/index.js";
4
+ import { scanAssetsSync } from "./scan.js";
5
+ const TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
6
+ "js",
7
+ "ts",
8
+ "tsx",
9
+ "jsx",
10
+ "mjs",
11
+ "cjs",
12
+ "json",
13
+ "yaml",
14
+ "yml",
15
+ "toml",
16
+ "css",
17
+ "scss",
18
+ "html",
19
+ "md",
20
+ "mdx",
21
+ "sh",
22
+ "bash",
23
+ "py",
24
+ "rb",
25
+ "go",
26
+ "rs",
27
+ "java",
28
+ "kt",
29
+ "swift",
30
+ "c",
31
+ "h",
32
+ "cpp",
33
+ "hpp",
34
+ "cs",
35
+ "php",
36
+ "sql",
37
+ "xml",
38
+ "txt",
39
+ "log",
40
+ "csv",
41
+ "tsv",
42
+ "env",
43
+ "conf",
44
+ "ini"
45
+ ]);
46
+ function hasBinaryPrefix(absPath) {
47
+ const fd = openSync(absPath, "r");
48
+ try {
49
+ const sample = Buffer.allocUnsafe(8 * 1024);
50
+ const bytesRead = readSync(fd, sample, 0, sample.length, 0);
51
+ return sample.subarray(0, bytesRead).includes(0);
52
+ } finally {
53
+ closeSync(fd);
54
+ }
55
+ }
56
+ function isTextAsset(absPath, path) {
57
+ const extension = extname(path).slice(1).toLowerCase();
58
+ if (extension.length !== 0 && !TEXT_EXTENSIONS.has(extension)) return false;
59
+ return !hasBinaryPrefix(absPath);
60
+ }
61
+ function isDefaultLocaleOnlyAsset(routePrefix, path, prefixes) {
62
+ const routePath = assetViewerHref({ base: "/", routePrefix, path });
63
+ return prefixes.some((prefix) => routePath.startsWith(prefix));
64
+ }
65
+ function hasConsumerGate(assetScan, consumer) {
66
+ const indexing = assetScan.assetViewerIndexing;
67
+ return assetScan.assetViewer === true && indexing !== false && typeof indexing === "object" && indexing !== null && indexing[consumer] === true;
68
+ }
69
+ function collectAssetPageDescriptors(options) {
70
+ const { projectRoot, assetScan, consumer } = options;
71
+ if (assetScan === void 0 || !hasConsumerGate(assetScan, consumer)) return [];
72
+ const dir = assetScan.assetViewerDir;
73
+ const routePrefix = assetScan.assetViewerRoutePrefix;
74
+ const base = assetScan.base;
75
+ const exclude = assetScan.assetViewerExclude ?? [];
76
+ const paths = scanAssetsSync(projectRoot, dir, exclude);
77
+ const assetRoot = resolve(projectRoot, "public", dir);
78
+ const descriptorFor = (path, locale) => ({
79
+ path,
80
+ url: assetViewerHref({ base, routePrefix, path, ...locale === void 0 ? {} : { locale } }),
81
+ ...locale === void 0 ? {} : { locale },
82
+ isText: isTextAsset(resolve(assetRoot, path), path),
83
+ size: statSync(resolve(assetRoot, path)).size
84
+ });
85
+ const descriptors = paths.map((path) => descriptorFor(path));
86
+ for (const locale of Object.keys(assetScan.locales ?? {})) {
87
+ for (const path of paths) {
88
+ if (isDefaultLocaleOnlyAsset(routePrefix, path, assetScan.defaultLocaleOnlyPrefixes ?? [])) {
89
+ continue;
90
+ }
91
+ descriptors.push(descriptorFor(path, locale));
92
+ }
93
+ }
94
+ return descriptors;
95
+ }
96
+ export {
97
+ collectAssetPageDescriptors
98
+ };
@@ -1,2 +1,19 @@
1
- /** Recursively enumerate safe, public assets in deterministic path order. */
1
+ /**
2
+ * Recursively enumerate safe, public assets in deterministic path order.
3
+ *
4
+ * This synchronous implementation is the canonical walk. Search and llms.txt
5
+ * build/dev consumers are synchronous, while the routes setup hook already
6
+ * exposes an async `scanAssets()` contract. Keeping one walk here gives both
7
+ * boundaries identical symlink, exclusion, and collision behavior.
8
+ */
9
+ export declare function scanAssetsSync(projectRoot: string, dir: string, exclude?: readonly string[]): string[];
10
+ /**
11
+ * Async-compatible asset scan used by the routes setup hook.
12
+ *
13
+ * The route plugin's public lifecycle contract is async, but its scan itself
14
+ * is only filesystem enumeration. Delegating to the canonical sync walk keeps
15
+ * it behaviorally identical to `scanAssetsSync()` without introducing a
16
+ * second implementation (or a promise that synchronous middleware cannot
17
+ * consume).
18
+ */
2
19
  export declare function scanAssets(projectRoot: string, dir: string, exclude?: readonly string[]): Promise<string[]>;
@@ -1,11 +1,27 @@
1
- import { readdir, realpath, stat } from "node:fs/promises";
1
+ import { readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { basename, relative, resolve, sep } from "node:path";
3
3
  import { matchExclude, normalizeAssetPath } from "../../../asset-path/index.js";
4
4
  function isWithin(root, candidate) {
5
5
  const rel = relative(root, candidate);
6
6
  return rel === "" || !rel.startsWith(`..${sep}`) && rel !== "..";
7
7
  }
8
- async function scanAssets(projectRoot, dir, exclude = []) {
8
+ function finishScan(paths) {
9
+ paths.sort((a, b) => a.localeCompare(b, "en"));
10
+ const folded = /* @__PURE__ */ new Map();
11
+ for (const path of paths) {
12
+ const key = path.toLocaleLowerCase("en-US");
13
+ const existing = folded.get(key);
14
+ if (existing !== void 0) {
15
+ throw new Error(`[asset-viewer] case-insensitive or Unicode-normalized URL collision: ${existing} and ${path}`);
16
+ }
17
+ folded.set(key, path);
18
+ if (path === "client" || path.startsWith("client/")) {
19
+ console.warn(`[asset-viewer] reserved client/ asset path: ${path}`);
20
+ }
21
+ }
22
+ return paths;
23
+ }
24
+ function scanAssetsSync(projectRoot, dir, exclude = []) {
9
25
  const publicRoot = resolve(projectRoot, "public");
10
26
  const assetRoot = resolve(publicRoot, dir);
11
27
  if (!isWithin(publicRoot, assetRoot)) {
@@ -13,8 +29,8 @@ async function scanAssets(projectRoot, dir, exclude = []) {
13
29
  }
14
30
  let canonicalRoot;
15
31
  try {
16
- const canonicalPublicRoot = await realpath(publicRoot);
17
- canonicalRoot = await realpath(assetRoot);
32
+ const canonicalPublicRoot = realpathSync(publicRoot);
33
+ canonicalRoot = realpathSync(assetRoot);
18
34
  if (!isWithin(canonicalPublicRoot, canonicalRoot)) {
19
35
  throw new Error(`[asset-viewer] asset directory symlink escapes public/: ${dir}`);
20
36
  }
@@ -23,29 +39,29 @@ async function scanAssets(projectRoot, dir, exclude = []) {
23
39
  throw error;
24
40
  }
25
41
  const paths = [];
26
- async function walk(absDir, relDir, canonicalAncestors) {
27
- const canonicalDir = await realpath(absDir);
42
+ function walk(absDir, relDir, canonicalAncestors) {
43
+ const canonicalDir = realpathSync(absDir);
28
44
  if (!isWithin(canonicalRoot, canonicalDir)) {
29
45
  throw new Error(`[asset-viewer] symlink escapes public/${dir}: ${relDir || basename(absDir)}`);
30
46
  }
31
47
  if (canonicalAncestors.has(canonicalDir)) return;
32
48
  const nextAncestors = new Set(canonicalAncestors).add(canonicalDir);
33
- const entries = await readdir(absDir, { withFileTypes: true });
49
+ const entries = readdirSync(absDir, { withFileTypes: true });
34
50
  entries.sort((a, b) => a.name.localeCompare(b.name, "en"));
35
51
  for (const entry of entries) {
36
52
  if (entry.name.startsWith(".")) continue;
37
53
  const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
38
54
  if (entry.name.endsWith(".meta.json")) continue;
39
55
  const absPath = resolve(absDir, entry.name);
40
- const canonical = await realpath(absPath);
56
+ const canonical = realpathSync(absPath);
41
57
  if (!isWithin(canonicalRoot, canonical)) {
42
58
  throw new Error(`[asset-viewer] symlink escapes public/${dir}: ${relPath}`);
43
59
  }
44
60
  if (entry.isDirectory()) {
45
- await walk(absPath, relPath, nextAncestors);
61
+ walk(absPath, relPath, nextAncestors);
46
62
  } else if (entry.isSymbolicLink()) {
47
- const target = await stat(canonical);
48
- if (target.isDirectory()) await walk(absPath, relPath, nextAncestors);
63
+ const target = statSync(canonical);
64
+ if (target.isDirectory()) walk(absPath, relPath, nextAncestors);
49
65
  else {
50
66
  const path = normalizeAssetPath(relPath);
51
67
  if (!matchExclude(path, exclude)) paths.push(path);
@@ -56,22 +72,13 @@ async function scanAssets(projectRoot, dir, exclude = []) {
56
72
  }
57
73
  }
58
74
  }
59
- await walk(assetRoot, "", /* @__PURE__ */ new Set());
60
- paths.sort((a, b) => a.localeCompare(b, "en"));
61
- const folded = /* @__PURE__ */ new Map();
62
- for (const path of paths) {
63
- const key = path.toLocaleLowerCase("en-US");
64
- const existing = folded.get(key);
65
- if (existing !== void 0) {
66
- throw new Error(`[asset-viewer] case-insensitive or Unicode-normalized URL collision: ${existing} and ${path}`);
67
- }
68
- folded.set(key, path);
69
- if (path === "client" || path.startsWith("client/")) {
70
- console.warn(`[asset-viewer] reserved client/ asset path: ${path}`);
71
- }
72
- }
73
- return paths;
75
+ walk(assetRoot, "", /* @__PURE__ */ new Set());
76
+ return finishScan(paths);
77
+ }
78
+ async function scanAssets(projectRoot, dir, exclude = []) {
79
+ return scanAssetsSync(projectRoot, dir, exclude);
74
80
  }
75
81
  export {
76
- scanAssets
82
+ scanAssets,
83
+ scanAssetsSync
77
84
  };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Materialize the shared asset-viewer descriptors for llms.txt output.
3
+ *
4
+ * The descriptor scanner owns the asset-viewer/indexing gates and URL rules;
5
+ * this adapter only reads the bounded text body needed by llms-full.txt.
6
+ * Keeping it separate from the emitter lets build and dev use precisely the
7
+ * same data preparation path.
8
+ */
9
+ import { type AssetScanProjection } from "../asset-viewer/asset-pages.js";
10
+ import { type LlmsAssetEntry } from "./types.js";
11
+ export interface LlmsAssetLoadOptions {
12
+ /** Runtime project root supplied by the zfb plugin wrapper. */
13
+ projectRoot?: string;
14
+ /** Shared serialized asset-viewer projection from the preset. */
15
+ assetScan?: AssetScanProjection;
16
+ /** Optional canonical site origin, matching document-entry URL behavior. */
17
+ siteUrl?: string;
18
+ }
19
+ /**
20
+ * Collect and materialize all llms-indexed assets. Missing runtime inputs are
21
+ * treated as the legacy no-assets case so direct unit callers and consumers
22
+ * that do not run through zfb remain byte-compatible.
23
+ */
24
+ export declare function loadLlmsAssetEntries(options: LlmsAssetLoadOptions): LlmsAssetEntry[];
@@ -0,0 +1,65 @@
1
+ import { closeSync, openSync, readSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { StringDecoder } from "node:string_decoder";
4
+ import {
5
+ collectAssetPageDescriptors
6
+ } from "../asset-viewer/asset-pages.js";
7
+ import {
8
+ LLMS_ASSET_TEXT_CAP_BYTES
9
+ } from "./types.js";
10
+ function readTextPrefix(filePath) {
11
+ const sample = Buffer.alloc(LLMS_ASSET_TEXT_CAP_BYTES + 1);
12
+ const fd = openSync(filePath, "r");
13
+ try {
14
+ let offset = 0;
15
+ while (offset < sample.length) {
16
+ const bytesRead = readSync(fd, sample, offset, sample.length - offset, offset);
17
+ if (bytesRead === 0) break;
18
+ offset += bytesRead;
19
+ }
20
+ const decoder = new StringDecoder("utf8");
21
+ return {
22
+ content: decoder.write(sample.subarray(0, offset)),
23
+ truncated: offset > LLMS_ASSET_TEXT_CAP_BYTES
24
+ };
25
+ } finally {
26
+ closeSync(fd);
27
+ }
28
+ }
29
+ function toLlmsAssetEntry(descriptor, projectRoot, assetScan, siteUrl) {
30
+ const url = siteUrl ? `${siteUrl.replace(/\/$/, "")}${descriptor.url}` : descriptor.url;
31
+ if (!descriptor.isText) {
32
+ return {
33
+ path: descriptor.path,
34
+ url,
35
+ ...descriptor.locale === void 0 ? {} : { locale: descriptor.locale },
36
+ isText: false
37
+ };
38
+ }
39
+ const body = readTextPrefix(
40
+ resolve(projectRoot, "public", assetScan.assetViewerDir, descriptor.path)
41
+ );
42
+ return {
43
+ path: descriptor.path,
44
+ url,
45
+ ...descriptor.locale === void 0 ? {} : { locale: descriptor.locale },
46
+ isText: true,
47
+ content: body.content,
48
+ truncated: body.truncated
49
+ };
50
+ }
51
+ function loadLlmsAssetEntries(options) {
52
+ const { projectRoot, assetScan, siteUrl } = options;
53
+ if (projectRoot === void 0 || assetScan === void 0) return [];
54
+ const descriptors = collectAssetPageDescriptors({
55
+ projectRoot,
56
+ assetScan,
57
+ consumer: "llmsTxt"
58
+ });
59
+ return descriptors.map(
60
+ (descriptor) => toLlmsAssetEntry(descriptor, projectRoot, assetScan, siteUrl)
61
+ );
62
+ }
63
+ export {
64
+ loadLlmsAssetEntries
65
+ };
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { LlmsTxtLocaleConfig, LlmsTxtSiteMeta } from "./types.js";
3
+ import type { AssetScanProjection } from "../asset-viewer/asset-pages.js";
3
4
  /** Connect-style middleware signature — works as a Vite plugin middleware. */
4
5
  export type LlmsTxtNextFn = (err?: unknown) => void;
5
6
  export type LlmsTxtMiddleware = (req: IncomingMessage, res: ServerResponse, next: LlmsTxtNextFn) => void;
@@ -22,6 +23,10 @@ export interface LlmsTxtDevMiddlewareOptions extends LlmsTxtSiteMeta {
22
23
  defaultLocaleDir: string;
23
24
  /** Additional locales (e.g. `[{ code: "ja", dir: "src/content/docs-ja" }]`). */
24
25
  locales?: LlmsTxtLocaleConfig[];
26
+ /** Runtime project root; injected by the zfb plugin wrapper, never serialized in the preset. */
27
+ projectRoot?: string;
28
+ /** Shared asset-viewer projection; consumed by the asset indexing wave. */
29
+ assetScan?: AssetScanProjection;
25
30
  }
26
31
  /**
27
32
  * Build a dev-server middleware that responds to GET requests for
@@ -1,3 +1,4 @@
1
+ import { loadLlmsAssetEntries } from "./assets.js";
1
2
  import { generateLlmsFullTxt, generateLlmsTxt } from "./generate.js";
2
3
  import { loadDocEntries } from "./load.js";
3
4
  const LLMS_KIND_PATTERN = /^(?:\/(.+?))?\/(llms|llms-full)\.txt$/;
@@ -39,7 +40,14 @@ function createLlmsTxtDevMiddleware(options, logger) {
39
40
  base,
40
41
  siteUrl
41
42
  });
42
- const body = match.kind === "llms" ? generateLlmsTxt(entries, meta) : generateLlmsFullTxt(entries, meta);
43
+ const assets = loadLlmsAssetEntries({
44
+ projectRoot: options.projectRoot,
45
+ assetScan: options.assetScan,
46
+ siteUrl
47
+ }).filter(
48
+ (asset) => match.locale === null ? asset.locale === void 0 : asset.locale === match.locale
49
+ );
50
+ const body = match.kind === "llms" ? generateLlmsTxt(entries, meta, assets) : generateLlmsFullTxt(entries, meta, assets);
43
51
  res.statusCode = 200;
44
52
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
45
53
  res.end(body);
@@ -1,7 +1,13 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { generateLlmsFullTxt, generateLlmsTxt } from "./generate.js";
4
+ import { loadLlmsAssetEntries } from "./assets.js";
4
5
  import { loadDocEntries } from "./load.js";
6
+ function assetsForLocale(assets, locale) {
7
+ return assets.filter(
8
+ (asset) => locale === null ? asset.locale === void 0 : asset.locale === locale
9
+ );
10
+ }
5
11
  function emitLlmsTxt(options) {
6
12
  const {
7
13
  outDir,
@@ -11,10 +17,13 @@ function emitLlmsTxt(options) {
11
17
  siteDescription,
12
18
  defaultLocaleDir,
13
19
  locales = [],
14
- logger
20
+ logger,
21
+ projectRoot,
22
+ assetScan
15
23
  } = options;
16
24
  const meta = { siteName, siteDescription };
17
25
  const written = [];
26
+ const assetEntries = loadLlmsAssetEntries({ projectRoot, assetScan, siteUrl });
18
27
  const defaultEntries = loadDocEntries({
19
28
  contentDir: defaultLocaleDir,
20
29
  locale: null,
@@ -24,8 +33,14 @@ function emitLlmsTxt(options) {
24
33
  mkdirSync(outDir, { recursive: true });
25
34
  const defaultIndexPath = join(outDir, "llms.txt");
26
35
  const defaultFullPath = join(outDir, "llms-full.txt");
27
- writeFileSync(defaultIndexPath, generateLlmsTxt(defaultEntries, meta));
28
- writeFileSync(defaultFullPath, generateLlmsFullTxt(defaultEntries, meta));
36
+ writeFileSync(
37
+ defaultIndexPath,
38
+ generateLlmsTxt(defaultEntries, meta, assetsForLocale(assetEntries, null))
39
+ );
40
+ writeFileSync(
41
+ defaultFullPath,
42
+ generateLlmsFullTxt(defaultEntries, meta, assetsForLocale(assetEntries, null))
43
+ );
29
44
  written.push(defaultIndexPath, defaultFullPath);
30
45
  logger?.info(
31
46
  `Generated llms.txt and llms-full.txt (${defaultEntries.length} pages)`
@@ -41,8 +56,9 @@ function emitLlmsTxt(options) {
41
56
  mkdirSync(localeDir, { recursive: true });
42
57
  const indexPath = join(localeDir, "llms.txt");
43
58
  const fullPath = join(localeDir, "llms-full.txt");
44
- writeFileSync(indexPath, generateLlmsTxt(localeEntries, meta));
45
- writeFileSync(fullPath, generateLlmsFullTxt(localeEntries, meta));
59
+ const localeAssets = assetsForLocale(assetEntries, code);
60
+ writeFileSync(indexPath, generateLlmsTxt(localeEntries, meta, localeAssets));
61
+ writeFileSync(fullPath, generateLlmsFullTxt(localeEntries, meta, localeAssets));
46
62
  written.push(indexPath, fullPath);
47
63
  logger?.info(
48
64
  `Generated ${code}/llms.txt and ${code}/llms-full.txt (${localeEntries.length} pages)`
@@ -9,14 +9,14 @@
9
9
  * Output format must remain byte-identical to the legacy Astro emitter
10
10
  * — the file shape is part of the project's public surface.
11
11
  */
12
- import type { LlmsDocEntry, LlmsTxtSiteMeta } from "./types.js";
12
+ import { type LlmsAssetEntry, type LlmsDocEntry, type LlmsTxtSiteMeta } from "./types.js";
13
13
  /**
14
14
  * Slim index — site header followed by a markdown bullet per page with
15
15
  * `[title](url): description`.
16
16
  */
17
- export declare function generateLlmsTxt(entries: readonly LlmsDocEntry[], meta: LlmsTxtSiteMeta): string;
17
+ export declare function generateLlmsTxt(entries: readonly LlmsDocEntry[], meta: LlmsTxtSiteMeta, assets?: readonly LlmsAssetEntry[]): string;
18
18
  /**
19
19
  * Full content concatenation — site header followed by every page's
20
20
  * stripped body separated by `---` rules.
21
21
  */
22
- export declare function generateLlmsFullTxt(entries: readonly LlmsDocEntry[], meta: LlmsTxtSiteMeta): string;
22
+ export declare function generateLlmsFullTxt(entries: readonly LlmsDocEntry[], meta: LlmsTxtSiteMeta, assets?: readonly LlmsAssetEntry[]): string;