create-zudo-doc 5.13.1 → 5.14.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to `create-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.14.0] - 2026-08-31
8
+
9
+ ### Features
10
+
11
+ - Added the `--asset-viewer` feature flag. Generated projects now emit `assetViewer: true` in `zfb.config.ts`, enabling the package-owned viewer for files under `public/assets/` without adding a host route stub or extra template files. (`45dc50cdb`)
12
+ - Generated doc-history projects now use the package-owned `run-parallel` command for their concurrent development servers, removing `npm-run-all2` and reporting unsupported trailing arguments instead of silently ignoring them. (`9402ff157`, `3ebfa6e9c`)
13
+
14
+ ### Bug Fixes
15
+
16
+ - Updated the generated link checker to recognize valid unquoted HTML attributes, decode character references, resolve percent-encoded routes, and avoid serialized-markup false positives. (`db9a07018`)
17
+
18
+ ### Other Changes
19
+
20
+ - Updated newly generated projects to use the zfb 2.14.0 package family and `@takazudo/zdtp` 0.4.14. (`dbe8553c7`, `038870c94`)
21
+
7
22
  ## [5.13.1] - 2026-08-28
8
23
 
9
24
  ### Bug Fixes
package/README.md CHANGED
@@ -76,6 +76,7 @@ Each feature has a `--[no-]<flag>` form. Passing `--feature` enables it; `--no-f
76
76
  | `--[no-]search` | Full-text search | on |
77
77
  | `--[no-]sidebar-filter` | Real-time sidebar filter | on |
78
78
  | `--[no-]image-enlarge` | Click-to-enlarge for oversized images | on |
79
+ | `--[no-]asset-viewer` | Viewer pages for files under `public/assets/` | off |
79
80
  | `--[no-]tag-governance` | Vocabulary-aware tag audit + suggest scripts | off |
80
81
  | `--[no-]claude-resources` | Auto-generate Claude Code docs from `.claude/` | off |
81
82
  | `--[no-]codex-resources` | Auto-generate Codex docs from `.codex/` + `AGENTS.md` | off |
@@ -29,10 +29,10 @@ export function generateCLAUDEFile(choices) {
29
29
  lines.push(``);
30
30
  const pm = choices.packageManager;
31
31
  if (choices.features.includes("docHistory")) {
32
- lines.push(`- \`${pmRunCommand(pm, "dev")}\` — runs the zfb dev server (port 4321) and the doc-history API server (port 4322) concurrently via \`run-p\` (\`${pmRunCommand(pm, "dev:zfb")}\` / \`${pmRunCommand(pm, "dev:history")}\` individually)`);
32
+ lines.push(`- \`${pmRunCommand(pm, "dev")}\` — runs the zfb dev server (port 4321) and the doc-history API server (port 4322) concurrently via \`run-parallel\` (a bin from @takazudo/zudo-doc) (\`${pmRunCommand(pm, "dev:zfb")}\` / \`${pmRunCommand(pm, "dev:history")}\` individually)`);
33
33
  lines.push(`- \`${pmRunCommand(pm, "dev:network")}\` — same, but zfb binds \`--host 0.0.0.0\` for LAN access (\`${pmRunCommand(pm, "dev:zfb:network")}\` individually); the doc-history server stays loopback-only and LAN clients reach it through zfb's \`/doc-history/*\` dev proxy`);
34
34
  lines.push(`- **Trusted networks only:** this also serves your git doc-history — including UNPUBLISHED local commits — to anyone on the LAN via the \`/doc-history/*\` proxy`);
35
- lines.push(`- \`run-p\` swallows trailing args, so other zfb flags don't forward through \`${pmRunCommand(pm, "dev")}\` pass them directly instead: \`${pm} run dev:zfb -- <flags>\``);
35
+ lines.push(`- \`run-parallel\` does not forward trailing args it rejects them with an error rather than silently ignoring them, so pass other zfb flags directly instead: \`${pm} run dev:zfb -- <flags>\``);
36
36
  }
37
37
  else {
38
38
  lines.push(`- \`${pmRunCommand(pm, "dev")}\` — zfb dev server (port 4321)`);
@@ -114,7 +114,7 @@ export function generateCLAUDEFile(choices) {
114
114
  lines.push(`- ${defaultLabel} (default): \`/docs/...\` — content in \`src/content/docs/\``);
115
115
  lines.push(`- ${secondaryLabel}: \`/${secondaryLang}/docs/...\` — content in \`src/content/docs-${secondaryLang}/\``);
116
116
  lines.push(`- ${secondaryLabel} docs should mirror the ${defaultLabel} directory structure`);
117
- lines.push(`- Both \`pages/docs/[[...slug]].tsx\` and \`pages/[locale]/docs/[[...slug]].tsx\` are self-contained doc-route stubs shipped by the generator required so \`${pm} dev\` doesn't 404 on doc pages (a zfb dev-mode limitation on package-injected dynamic routes). Don't delete them.`);
117
+ lines.push(`- Both \`pages/docs/[[...slug]].tsx\` and \`pages/[locale]/docs/[[...slug]].tsx\` are self-contained doc-route stubs shipped by the generator as explicit host-owned seams. zfb 2.13.1 also renders package-injected dynamic routes in dev; keep these files so the generated project retains route ownership and customization.`);
118
118
  lines.push(``);
119
119
  }
120
120
  // Enabled features
package/dist/constants.js CHANGED
@@ -331,6 +331,13 @@ export const FEATURES = [
331
331
  default: true,
332
332
  cliFlag: "image-enlarge",
333
333
  },
334
+ {
335
+ value: "assetViewer",
336
+ label: "Asset viewer",
337
+ hint: "Viewer pages for files under public/assets",
338
+ default: false,
339
+ cliFlag: "asset-viewer",
340
+ },
334
341
  {
335
342
  value: "dynamicPageTransition",
336
343
  label: "Dynamic page transition",
@@ -0,0 +1,10 @@
1
+ import type { FeatureModule } from "../compose.js";
2
+ /**
3
+ * Asset-viewer feature.
4
+ *
5
+ * Purely a `zudoDoc({ assetViewer: true })` field (see `zfb-config-gen.ts`).
6
+ * The scanner, viewer route, and all supporting UI are package-owned by
7
+ * `@takazudo/zudo-doc`; generated projects only need the config toggle.
8
+ * Nothing is injected or copied into the scaffold.
9
+ */
10
+ export declare const assetViewerFeature: FeatureModule;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Asset-viewer feature.
3
+ *
4
+ * Purely a `zudoDoc({ assetViewer: true })` field (see `zfb-config-gen.ts`).
5
+ * The scanner, viewer route, and all supporting UI are package-owned by
6
+ * `@takazudo/zudo-doc`; generated projects only need the config toggle.
7
+ * Nothing is injected or copied into the scaffold.
8
+ */
9
+ export const assetViewerFeature = () => ({
10
+ name: "assetViewer",
11
+ injections: [],
12
+ });
@@ -9,7 +9,7 @@ import type { FeatureModule } from "../compose.js";
9
9
  * since the host catch-all stubs were retired in favor of package
10
10
  * injection — there is nothing left to copy or postProcess.
11
11
  *
12
- * Known limitation: same injected-DYNAMIC-route dev-mode gap noted on the
13
- * versioning feature the tag routes may 404 in `zfb dev` (build is fine).
12
+ * zfb 2.13.1 renders these injected tag routes in both dev and build. This
13
+ * feature has no host stubs because package injection owns the routes.
14
14
  */
15
15
  export declare const docTagsFeature: FeatureModule;
@@ -8,8 +8,8 @@
8
8
  * since the host catch-all stubs were retired in favor of package
9
9
  * injection — there is nothing left to copy or postProcess.
10
10
  *
11
- * Known limitation: same injected-DYNAMIC-route dev-mode gap noted on the
12
- * versioning feature the tag routes may 404 in `zfb dev` (build is fine).
11
+ * zfb 2.13.1 renders these injected tag routes in both dev and build. This
12
+ * feature has no host stubs because package injection owns the routes.
13
13
  */
14
14
  export const docTagsFeature = () => ({
15
15
  name: "docTags",
@@ -4,8 +4,8 @@ import type { FeatureModule } from "../compose.js";
4
4
  *
5
5
  * Locked manifest (#2653 Decision 4, i18n addendum): "i18n ON adds
6
6
  * `pages/[locale]/docs/[[...slug]].tsx` (a second doc stub, locale variant)
7
- * … No other pages." That ONE self-contained stub — required for the same
8
- * injected-DYNAMIC-route dev-mode 404 gap the default-locale stub fixes
7
+ * … No other pages." That ONE self-contained stub — retained as an explicit
8
+ * host-owned route seam; zfb 2.13.1 also serves injected dynamic routes in dev
9
9
  * is shipped under `templates/features/i18n/files/pages/[locale]/docs/`
10
10
  * and copied by `composeFeatures → copyFeatureFiles` whenever `i18n` is
11
11
  * selected. The old `pages/[locale]/index.tsx` home-route template is GONE
@@ -6,8 +6,8 @@ import { fileURLToPath } from "url";
6
6
  *
7
7
  * Locked manifest (#2653 Decision 4, i18n addendum): "i18n ON adds
8
8
  * `pages/[locale]/docs/[[...slug]].tsx` (a second doc stub, locale variant)
9
- * … No other pages." That ONE self-contained stub — required for the same
10
- * injected-DYNAMIC-route dev-mode 404 gap the default-locale stub fixes
9
+ * … No other pages." That ONE self-contained stub — retained as an explicit
10
+ * host-owned route seam; zfb 2.13.1 also serves injected dynamic routes in dev
11
11
  * is shipped under `templates/features/i18n/files/pages/[locale]/docs/`
12
12
  * and copied by `composeFeatures → copyFeatureFiles` whenever `i18n` is
13
13
  * selected. The old `pages/[locale]/index.tsx` home-route template is GONE
@@ -22,6 +22,7 @@ import { versioningFeature } from "./versioning.js";
22
22
  import { tauriFeature } from "./tauri.js";
23
23
  import { tauriDevFeature } from "./tauri-dev.js";
24
24
  import { imageEnlargeFeature } from "./image-enlarge.js";
25
+ import { assetViewerFeature } from "./asset-viewer.js";
25
26
  import { dynamicPageTransitionFeature } from "./dynamic-page-transition.js";
26
27
  import { tagGovernanceFeature } from "./tag-governance.js";
27
28
  import { footerTaglistFeature } from "./footer-taglist.js";
@@ -50,6 +51,7 @@ export const featureModules = {
50
51
  tauri: tauriFeature,
51
52
  tauriDev: tauriDevFeature,
52
53
  imageEnlarge: imageEnlargeFeature,
54
+ assetViewer: assetViewerFeature,
53
55
  dynamicPageTransition: dynamicPageTransitionFeature,
54
56
  // skillSymlinker — handled in scaffold.ts
55
57
  // claudeSkills — handled in scaffold.ts (copies zudo-doc-* skills from monorepo)
@@ -9,11 +9,8 @@ import type { FeatureModule } from "../compose.js";
9
9
  * `renderDocPage` chrome. `templates/features/versioning/files/` has been
10
10
  * empty since #2390 — there is no host stub left to copy or postProcess.
11
11
  *
12
- * Known limitation (pre-existing, not introduced by the minimal-scaffold
13
- * cutover inherited from the same injected-DYNAMIC-route dev-mode gap the
14
- * locked manifest's `pages/docs/[[...slug]].tsx` stub exists to fix for the
15
- * primary doc route, tracked as a #2667 follow-up): the versioned doc
16
- * routes may still 404 in `zfb dev` since versioning has no stub of its own
17
- * in the locked manifest. `zfb build` is unaffected.
12
+ * zfb 2.13.1 renders these injected version routes in both dev and build, so
13
+ * versioning needs no version-specific host stub. The primary and locale doc
14
+ * stubs remain explicit host-owned seams.
18
15
  */
19
16
  export declare const versioningFeature: FeatureModule;
@@ -8,12 +8,9 @@
8
8
  * `renderDocPage` chrome. `templates/features/versioning/files/` has been
9
9
  * empty since #2390 — there is no host stub left to copy or postProcess.
10
10
  *
11
- * Known limitation (pre-existing, not introduced by the minimal-scaffold
12
- * cutover inherited from the same injected-DYNAMIC-route dev-mode gap the
13
- * locked manifest's `pages/docs/[[...slug]].tsx` stub exists to fix for the
14
- * primary doc route, tracked as a #2667 follow-up): the versioned doc
15
- * routes may still 404 in `zfb dev` since versioning has no stub of its own
16
- * in the locked manifest. `zfb build` is unaffected.
11
+ * zfb 2.13.1 renders these injected version routes in both dev and build, so
12
+ * versioning needs no version-specific host stub. The primary and locale doc
13
+ * stubs remain explicit host-owned seams.
17
14
  */
18
15
  export const versioningFeature = () => ({
19
16
  name: "versioning",
@@ -32,5 +32,5 @@ export declare function deriveDocSkillName(projectName: string): string;
32
32
  *
33
33
  * Bumped in lockstep by scripts/release-create-zudo-doc.sh.
34
34
  */
35
- export declare const ZUDO_DOC_PIN = "^5.13.1";
35
+ export declare const ZUDO_DOC_PIN = "^5.14.0";
36
36
  export declare function scaffold(choices: UserChoices): Promise<void>;
package/dist/scaffold.js CHANGED
@@ -43,7 +43,7 @@ export function deriveDocSkillName(projectName) {
43
43
  *
44
44
  * Bumped in lockstep by scripts/release-create-zudo-doc.sh.
45
45
  */
46
- export const ZUDO_DOC_PIN = "^5.13.1";
46
+ export const ZUDO_DOC_PIN = "^5.14.0";
47
47
  /**
48
48
  * Files in `templates/base/**` that must not be copied by the unconditional
49
49
  * base mirror. Each entry is matched against the path relative to
@@ -715,15 +715,15 @@ function generatePackageJson(choices) {
715
715
  // one CSS entrypoint without route discovery or page rendering, and fixes
716
716
  // a Back race before ClientRouter DOM swaps. Both changes are additive
717
717
  // tooling/runtime behavior; a fresh scaffold needs no config migration.
718
- // 2.13.1: toolchain-only. The published JS tarballs for `@takazudo/zfb`,
719
- // `zfb-runtime`, and `zfb-adapter-cloudflare` differ from 2.12.0 in their
720
- // package.json version fields alone 2.13.0 made CI-attested publishing
721
- // the default, and 2.13.1's livereload bfcache-lifecycle fix lives in the
722
- // per-platform Rust binary. `zfb-md-wasm` rebuilt its WASM with a
723
- // byte-identical export surface. No generator-side migration.
724
- "@takazudo/zfb": "2.13.1",
725
- "@takazudo/zfb-runtime": "2.13.1",
726
- "@takazudo/zfb-md-wasm": "2.13.1",
718
+ // 2.14.0: additive behavior and bug fixes. zfb can bundle authored CSS
719
+ // imports when Tailwind is off, scans claimed root-package CSS sources,
720
+ // and adds the optional `strictPlainCssImports` flag (default false).
721
+ // Parser fixes also ship in zfb-md-wasm; runtime and the Cloudflare adapter
722
+ // have no package-specific behavior change. Public exports and engine
723
+ // requirements stay unchanged, so a fresh scaffold needs no migration.
724
+ "@takazudo/zfb": "2.14.0",
725
+ "@takazudo/zfb-runtime": "2.14.0",
726
+ "@takazudo/zfb-md-wasm": "2.14.0",
727
727
  // @takazudo/zudo-doc — published from this monorepo via
728
728
  // .github/workflows/publish-zudo-doc.yml. The pin here is bumped in
729
729
  // lockstep by scripts/release-create-zudo-doc.sh whenever zudo-doc's
@@ -735,6 +735,13 @@ function generatePackageJson(choices) {
735
735
  // ties this pin to packages/zudo-doc's version, so the lockstep release
736
736
  // bumps both together; do not cut a create-zudo-doc release until the
737
737
  // matching @takazudo/zudo-doc version (with content.css) is on npm.
738
+ // RELEASE DEPENDENCY (same shape as the content.css one above): the
739
+ // docHistory `dev`/`dev:network` scripts now invoke `run-parallel`, a bin
740
+ // added to @takazudo/zudo-doc in this monorepo but NOT present in published
741
+ // 5.13.1. `^5.13.1` floats up, so a fresh install resolves the bumped
742
+ // version — but do not cut a create-zudo-doc release until a
743
+ // @takazudo/zudo-doc that ships bin/run-parallel.mjs is on npm, or every
744
+ // docHistory scaffold fails at `dev` with `run-parallel: not found`.
738
745
  // ZUDO_DOC_PIN is the shared constant — scaffold() uses the same value
739
746
  // to seed .zudo-doc.json so the provenance and the dep can never drift.
740
747
  "@takazudo/zudo-doc": ZUDO_DOC_PIN,
@@ -796,9 +803,9 @@ function generatePackageJson(choices) {
796
803
  // same reason. This is the ACCEPTED, permanent contract per #2668 — see
797
804
  // the "@takazudo/zdtp dep implication" note in
798
805
  // packages/zudo-doc/docs/adr/route-injection-seam.md.
799
- "@takazudo/zdtp": "0.4.12",
806
+ "@takazudo/zdtp": "0.4.14",
800
807
  // (@takazudo/zudo-doc-history-server is NOT here — it is gated on the
801
- // docHistory feature, see the block below. It was briefly unconditional
808
+ // docHistory or assetViewer features, see the block below. It was briefly unconditional
802
809
  // (#3080) to work around doc-history-area importing its `/exclude` subpath
803
810
  // at module scope; that root cause was fixed in #3110 by moving
804
811
  // compileExclude into @takazudo/zudo-doc itself, so the workaround is gone
@@ -824,36 +831,31 @@ function generatePackageJson(choices) {
824
831
  // search ships as @takazudo/zudo-doc's own self-contained generated
825
832
  // search-widget script (custom word-match scorer) — no third-party search
826
833
  // engine dependency is needed here. Do not re-add minisearch/pagefind.
827
- if (choices.features.includes("docHistory")) {
834
+ if (choices.features.includes("docHistory") ||
835
+ choices.features.includes("assetViewer")) {
828
836
  // (`diff` remains an unconditional base dep — see the `deps` block above:
829
837
  // packageOwnedRoutes always bundles the doc-history-area path, whose
830
838
  // module-scope `diff` import is pulled in regardless of this flag. #2342.)
831
839
  //
832
- // @takazudo/zudo-doc-history-server is gated HERE, on the feature, because
833
- // that is the only graph that actually reaches it: the zfb plugin
834
- // (@takazudo/zudo-doc/plugins/doc-history) eagerly imports
835
- // @takazudo/zudo-doc-history-server/git-history at plugin-init time, and the
836
- // plugin is only wired when docHistory is on (W8A #1739). It is an optional
837
- // peerDependency of @takazudo/zudo-doc and pnpm does not auto-install peers,
838
- // so a docHistory-ON project must declare it directly. The pin stays lockstep
840
+ // @takazudo/zudo-doc-history-server is gated HERE because docHistory and
841
+ // assetViewer are the only feature graphs that reach it. The doc-history
842
+ // plugin imports `/git-history` at plugin-init time; the asset viewer loads
843
+ // that same subpath lazily to populate Created/Updated/Author metadata.
844
+ // It is an optional peerDependency of @takazudo/zudo-doc and is not
845
+ // auto-installed for those feature graphs, so either feature must declare
846
+ // it directly. The pin stays lockstep
839
847
  // with the root version (parity-guarded — INTERNAL_PINNED_PACKAGES in
840
848
  // scripts/check-pin-parity.mjs, and rewritten at release time by
841
849
  // scripts/release-create-zudo-doc.sh step 2d).
842
850
  //
843
851
  // It was briefly unconditional (#3080) because doc-history-area imported
844
852
  // `/exclude` at module scope from the always-bundled chrome graph; #3110
845
- // moved compileExclude into @takazudo/zudo-doc, so docHistory-OFF projects
846
- // no longer need the package at all.
847
- deps["@takazudo/zudo-doc-history-server"] = "^5.13.1";
853
+ // moved compileExclude into @takazudo/zudo-doc, so projects with both
854
+ // docHistory and assetViewer off no longer need the package at all.
855
+ deps["@takazudo/zudo-doc-history-server"] = "^5.14.0";
848
856
  // tsx is no longer needed here: the relocated package plugin imports the
849
857
  // runner directly (no `tsx -e` spawn) since the package ships compiled
850
858
  // dist/ — package-first migration #2321 (#2337).
851
- // npm-run-all2 provides `run-p`, used by the docHistory `dev` script
852
- // (below) to run the zfb dev server and the doc-history API server
853
- // concurrently — otherwise the :4322 proxy target never starts and the
854
- // feature silently looks broken (#2926). Same maintained fork/pin this
855
- // monorepo's own root package.json uses.
856
- devDeps["npm-run-all2"] = "^7.0.2";
857
859
  }
858
860
  // claudeResources: tsx is no longer needed. The relocated package plugin
859
861
  // (@takazudo/zudo-doc/plugins/claude-resources) imports the runner directly
@@ -879,20 +881,25 @@ function generatePackageJson(choices) {
879
881
  // running concurrently — otherwise the Created/Updated/Author block
880
882
  // silently never appears in dev (#2926). `doc-history-server` is the bin
881
883
  // shipped by the @takazudo/zudo-doc-history-server dep added above;
882
- // `run-p` (npm-run-all2, added to devDependencies above) runs both.
883
- scripts.dev = "run-p dev:zfb dev:history";
884
+ // `run-parallel` runs both. It is a bin shipped by @takazudo/zudo-doc — an
885
+ // unconditional dep added above — so concurrency costs this project no extra
886
+ // devDependency. It replaced npm-run-all2's `run-p`, which was the sole
887
+ // source of four advisories (shell-quote DoS; brace-expansion@2 DoS x3).
888
+ scripts.dev = "run-parallel dev:zfb dev:history";
884
889
  scripts["dev:zfb"] = "zfb dev";
885
- // run-p swallows trailing args and npm-run-all2 v7's `{@}` placeholder
886
- // strips flag names, so `pnpm dev -- --host 0.0.0.0` is silently ignored
887
- // (verified in issue #2940) dev:network is a dedicated LAN-bound script
890
+ // Neither runner forwards trailing args, but they differ in how loudly: run-p
891
+ // SILENTLY ignored `pnpm dev -- --host 0.0.0.0` (verified in issue #2940),
892
+ // whereas run-parallel rejects it with an error naming the remedy — silence
893
+ // about a flag the user clearly meant is the "quiet lie" #3129 argues against.
894
+ // Either way dev:network is the LAN-bound script
888
895
  // instead. Only zfb binds 0.0.0.0; the history server stays loopback-only
889
896
  // and LAN clients reach it through zfb's `/doc-history/*` dev proxy.
890
897
  scripts["dev:zfb:network"] = "zfb dev --host 0.0.0.0";
891
- scripts["dev:network"] = "run-p dev:zfb:network dev:history";
898
+ scripts["dev:network"] = "run-parallel dev:zfb:network dev:history";
892
899
  // Relative --content-dir/--locale paths are resolved by resolveContentPath
893
900
  // (packages/doc-history-server/src/args.ts) against INIT_CWD (falling back
894
901
  // to process.cwd()) — correct for the supported invocation (`<pm> dev` /
895
- // `<pm> run dev` from the project root, which is what run-p's child
902
+ // `<pm> run dev` from the project root, which is what run-parallel's child
896
903
  // processes inherit). It resolves against the WRONG directory only if this
897
904
  // generated project is itself nested inside a larger pnpm/npm workspace
898
905
  // and dev:history is invoked via `<pm> --filter <this-package> ...` from
@@ -62,6 +62,11 @@ export const DEFAULT_MIRROR = {
62
62
  dynamicPageTransition: false,
63
63
  docHistory: false,
64
64
  docHistoryExclude: [],
65
+ assetViewer: false,
66
+ assetViewerDir: "assets",
67
+ assetViewerRoutePrefix: "files",
68
+ assetViewerExclude: [],
69
+ assetViewerIndex: false,
65
70
  bodyFootUtilArea: false,
66
71
  versions: false,
67
72
  claudeResources: false,
@@ -230,6 +235,7 @@ function buildDesiredConfig(choices) {
230
235
  desired.findInPage = choices.features.includes("tauri");
231
236
  desired.dynamicPageTransition = choices.features.includes("dynamicPageTransition");
232
237
  desired.docHistory = choices.features.includes("docHistory");
238
+ desired.assetViewer = choices.features.includes("assetViewer");
233
239
  if (choices.features.includes("bodyFootUtil")) {
234
240
  desired.bodyFootUtilArea = {
235
241
  docHistory: choices.features.includes("docHistory"),
@@ -391,6 +397,11 @@ const FIELD_ORDER = [
391
397
  "findInPage",
392
398
  "dynamicPageTransition",
393
399
  "docHistory",
400
+ "assetViewer",
401
+ "assetViewerDir",
402
+ "assetViewerRoutePrefix",
403
+ "assetViewerExclude",
404
+ "assetViewerIndex",
394
405
  "bodyFootUtilArea",
395
406
  "versions",
396
407
  "claudeResources",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zudo-doc",
3
- "version": "5.13.1",
3
+ "version": "5.14.0",
4
4
  "description": "Create a new zudo-doc documentation site",
5
5
  "license": "MIT",
6
6
  "author": "Takeshi Takatsudo",
@@ -1,10 +1,8 @@
1
1
  /** @jsxRuntime automatic */
2
2
  /** @jsxImportSource preact */
3
- // Locked manifest (#2653 Decision 4): a SELF-CONTAINED doc-route stub
4
- // REQUIRED because the injected DYNAMIC `/docs/[[...slug]]` route 404s in
5
- // `zfb dev` (real pre-existing gap in zfb's dev-mode dynamic-route rendering,
6
- // distinct from the `/`-injection gap zfb#1227; empirically confirmed on
7
- // #2653). This stub reconstructs the doc route from scratch using ONLY the
3
+ // Locked manifest (#2653 Decision 4): a SELF-CONTAINED doc-route stub retained
4
+ // for explicit host route ownership. zfb 2.13.1 also serves injected dynamic
5
+ // routes in dev. This stub reconstructs the doc route from scratch using ONLY the
8
6
  // sanctioned package entrypoints — no `pages/lib`, no `@/config`:
9
7
  // 1. the `virtual:zudo-doc-route-context` virtual module (serializable
10
8
  // settings/translations/tagVocabulary/colorSchemes payload),
@@ -14,9 +12,9 @@
14
12
  // The bindings import is unconditional: the routes plugin supplies an empty
15
13
  // object when `chromeBindingsModule` is unset, while configured projects get
16
14
  // their MDX/chrome bindings without editing this stub.
17
- // Makes `/docs/getting-started/` return 200 in BOTH `zfb dev` and `zfb build`
18
- // (see the "TM negative guard" case in route-injection-build.slow.test.ts for
19
- // the no-stub 404 proof this fixes).
15
+ // Makes `/docs/getting-started/` return 200 in BOTH `zfb dev` and `zfb build`.
16
+ // The route-injection build test covers the corresponding package-owned route;
17
+ // this kept file remains the explicit host-owned seam.
20
18
  //
21
19
  // docHistory note: when the docHistory feature is selected, the generator
22
20
  // patches this file to statically import DocHistory from
@@ -425,14 +425,34 @@ export async function parseContentDirs(configPath) {
425
425
  // Shared link and anchor logic
426
426
  // ---------------------------------------------------------------------------
427
427
 
428
+ const HTML_NAMED_CHARACTER_REFERENCES = {
429
+ amp: "&",
430
+ apos: "'",
431
+ gt: ">",
432
+ lt: "<",
433
+ quot: '"',
434
+ };
435
+
436
+ function decodeHtmlAttributeValue(value) {
437
+ return value.replace(
438
+ /&(?:#([0-9]+)|#x([0-9a-f]+)|(amp|apos|gt|lt|quot));/gi,
439
+ (_reference, decimal, hexadecimal, named) => {
440
+ if (named !== undefined) return HTML_NAMED_CHARACTER_REFERENCES[named.toLowerCase()];
441
+ const codePoint = Number.parseInt(hexadecimal ?? decimal, hexadecimal === undefined ? 10 : 16);
442
+ if (codePoint === 0 || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return "\uFFFD";
443
+ return String.fromCodePoint(codePoint);
444
+ },
445
+ );
446
+ }
447
+
428
448
  export function extractHtmlLinks(html) {
429
449
  const links = [];
430
- const regex = /<a\s[^>]*?href=(?:"([^"]*)"|'([^']*)')[^>]*>/gi;
450
+ const regex = /<a(?=\s)[^>]*?\shref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`\\]+))[^>]*>/gi;
431
451
  let match;
432
452
  let lastIndex = 0;
433
453
  let line = 1;
434
454
  while ((match = regex.exec(html)) !== null) {
435
- const href = match[1] ?? match[2];
455
+ const href = decodeHtmlAttributeValue(match[1] ?? match[2] ?? match[3]);
436
456
  if (/^(?:https?:|mailto:|javascript:|data:|tel:)/i.test(href)) continue;
437
457
  for (let i = lastIndex; i < match.index; i += 1) if (html[i] === "\n") line += 1;
438
458
  lastIndex = match.index;
@@ -441,6 +461,16 @@ export function extractHtmlLinks(html) {
441
461
  return links;
442
462
  }
443
463
 
464
+ export function extractHtmlIds(html) {
465
+ const ids = [];
466
+ const regex = /<[A-Za-z][^>]*?\sid\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`\\]+))[^>]*>/gi;
467
+ let match;
468
+ while ((match = regex.exec(html)) !== null) {
469
+ ids.push(decodeHtmlAttributeValue(match[1] ?? match[2] ?? match[3]));
470
+ }
471
+ return ids;
472
+ }
473
+
444
474
  function safeDecodePath(path) {
445
475
  try {
446
476
  return decodeURIComponent(path);
@@ -455,12 +485,12 @@ function parseHref(href) {
455
485
  const queryAt = beforeFragment.indexOf("?");
456
486
  const rawPath = queryAt === -1 ? beforeFragment : beforeFragment.slice(0, queryAt);
457
487
  const rawFragment = hashAt === -1 ? null : href.slice(hashAt + 1);
458
- if (rawFragment === null) return { path: safeDecodePath(rawPath), fragment: null, fragmentError: null };
459
- if (rawFragment === "") return { path: safeDecodePath(rawPath), fragment: "", fragmentError: "empty fragment" };
488
+ if (rawFragment === null) return { path: safeDecodePath(rawPath), rawPath, fragment: null, fragmentError: null };
489
+ if (rawFragment === "") return { path: safeDecodePath(rawPath), rawPath, fragment: "", fragmentError: "empty fragment" };
460
490
  try {
461
- return { path: safeDecodePath(rawPath), fragment: decodeURIComponent(rawFragment), fragmentError: null };
491
+ return { path: safeDecodePath(rawPath), rawPath, fragment: decodeURIComponent(rawFragment), fragmentError: null };
462
492
  } catch {
463
- return { path: safeDecodePath(rawPath), fragment: rawFragment, fragmentError: "malformed percent-encoding" };
493
+ return { path: safeDecodePath(rawPath), rawPath, fragment: rawFragment, fragmentError: "malformed percent-encoding" };
464
494
  }
465
495
  }
466
496
 
@@ -646,23 +676,40 @@ export async function checkMdxAnchors(contentDirs, rootDir, basePath = "/", loca
646
676
  return anchors;
647
677
  }
648
678
 
649
- async function resolveDistTarget(href, distDir, basePath = "/", fileDir = "", sourceFile = null) {
650
- const { path: clean, fragment, fragmentError } = parseHref(href);
651
- if (!clean) return { type: "root", targetFile: sourceFile ?? join(distDir, "index.html"), fragment, fragmentError };
652
- let absolute = clean;
653
- if (!clean.startsWith("/")) absolute = "/" + join(fileDir ? relative(distDir, fileDir) : "", clean);
679
+ async function resolveBuiltPath(path, distDir, basePath, fileDir) {
680
+ let absolute = path;
681
+ if (!path.startsWith("/")) absolute = "/" + join(fileDir ? relative(distDir, fileDir) : "", path);
654
682
  let stripped = absolute;
655
683
  if (basePath !== "/" && stripped.startsWith(basePath)) stripped = "/" + stripped.slice(basePath.length);
656
684
  const relPath = stripped.startsWith("/") ? stripped.slice(1) : stripped;
657
- if (!relPath) return { type: "root", targetFile: join(distDir, "index.html"), fragment, fragmentError };
685
+ if (!relPath) return { type: "root", targetFile: join(distDir, "index.html") };
686
+ // A terminal slash is an explicit directory request, even when the
687
+ // directory name contains a dot (for example, /files/demo/x.js/). Check it
688
+ // before extname() so viewer pages can keep their trailing-slash route and
689
+ // still receive fragment validation against index.html.
690
+ if (relPath.endsWith("/")) {
691
+ const indexFile = join(distDir, relPath, "index.html");
692
+ return (await fileExists(indexFile)) ? { type: "directoryIndex", targetFile: indexFile } : { type: "missing", targetFile: null };
693
+ }
658
694
  if (extname(relPath)) {
659
695
  const targetFile = join(distDir, relPath);
660
- return { type: (await fileExists(targetFile)) ? "file" : "missing", targetFile, fragment, fragmentError };
696
+ return (await fileExists(targetFile)) ? { type: "file", targetFile } : { type: "missing", targetFile: null };
661
697
  }
662
698
  const indexFile = join(distDir, relPath, "index.html");
663
- if (await fileExists(indexFile)) return { type: "directoryIndex", targetFile: indexFile, fragment, fragmentError };
699
+ if (await fileExists(indexFile)) return { type: "directoryIndex", targetFile: indexFile };
664
700
  const htmlFile = join(distDir, relPath + ".html");
665
- if (await fileExists(htmlFile)) return { type: "file", targetFile: htmlFile, fragment, fragmentError };
701
+ if (await fileExists(htmlFile)) return { type: "file", targetFile: htmlFile };
702
+ return { type: "missing", targetFile: null };
703
+ }
704
+
705
+ async function resolveDistTarget(href, distDir, basePath = "/", fileDir = "", sourceFile = null) {
706
+ const { path: decodedPath, rawPath, fragment, fragmentError } = parseHref(href);
707
+ if (!rawPath) return { type: "root", targetFile: sourceFile ?? join(distDir, "index.html"), fragment, fragmentError };
708
+ const pathCandidates = rawPath === decodedPath ? [rawPath] : [rawPath, decodedPath];
709
+ for (const path of pathCandidates) {
710
+ const detail = await resolveBuiltPath(path, distDir, basePath, fileDir);
711
+ if (detail.type !== "missing") return { ...detail, fragment, fragmentError };
712
+ }
666
713
  return { type: "missing", targetFile: null, fragment, fragmentError };
667
714
  }
668
715
 
@@ -686,9 +733,19 @@ export async function checkHtmlLinksAndTrailing(
686
733
  const trailingSlash = [];
687
734
  const idCache = new Map();
688
735
  const cache = new Map();
736
+ const pages = [];
737
+ const scanned = { links: 0, ids: 0 };
689
738
  for (const file of await collectFiles(distDir, [".html"])) {
690
739
  const content = await readFile(file, "utf-8");
691
- for (const { href, line } of extractHtmlLinks(content)) {
740
+ const links = extractHtmlLinks(content);
741
+ const ids = extractHtmlIds(content);
742
+ scanned.links += links.length;
743
+ scanned.ids += ids.length;
744
+ idCache.set(file, new Set(ids));
745
+ pages.push({ file, links });
746
+ }
747
+ for (const { file, links } of pages) {
748
+ for (const { href, line } of links) {
692
749
  if (excludePatterns.some((pattern) => pattern.test(href))) continue;
693
750
  const cacheKey = href.startsWith("/") ? href : `${file}:${href}`;
694
751
  let detail = cache.get(cacheKey);
@@ -703,10 +760,9 @@ export async function checkHtmlLinksAndTrailing(
703
760
  let ids = idCache.get(detail.targetFile);
704
761
  if (ids === undefined) {
705
762
  const targetHtml = await readFile(detail.targetFile, "utf-8");
706
- ids = new Set();
707
- const idRegex = /\bid\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
708
- let idMatch;
709
- while ((idMatch = idRegex.exec(targetHtml)) !== null) ids.add(idMatch[1] ?? idMatch[2]);
763
+ const targetIds = extractHtmlIds(targetHtml);
764
+ scanned.ids += targetIds.length;
765
+ ids = new Set(targetIds);
710
766
  idCache.set(detail.targetFile, ids);
711
767
  }
712
768
  if (!ids.has(detail.fragment)) reason = "missing target id";
@@ -721,7 +777,7 @@ export async function checkHtmlLinksAndTrailing(
721
777
  }
722
778
  }
723
779
  }
724
- return { broken, anchors, trailingSlash };
780
+ return { broken, anchors, trailingSlash, scanned };
725
781
  }
726
782
 
727
783
  export async function checkMdxLinks(
@@ -796,8 +852,8 @@ async function main() {
796
852
  console.log(`Checking links (base: ${config.basePath}, trailingSlash: ${config.trailingSlash})...`);
797
853
  console.log(`Source scan: ${contentDirs.map((dir) => relative(rootDir, dir) || ".").join(", ")}${hasDist ? "; dist/ pass enabled" : "; dist/ absent (source-only)"}\n`);
798
854
 
799
- const [{ broken, anchors: htmlAnchors, trailingSlash }, mdxWarnings, mdxAnchors] = await Promise.all([
800
- hasDist ? checkHtmlLinksAndTrailing(distDir, rootDir, config.basePath, excludePatterns, config.trailingSlash) : Promise.resolve({ broken: [], anchors: [], trailingSlash: [] }),
855
+ const [{ broken, anchors: htmlAnchors, trailingSlash, scanned }, mdxWarnings, mdxAnchors] = await Promise.all([
856
+ hasDist ? checkHtmlLinksAndTrailing(distDir, rootDir, config.basePath, excludePatterns, config.trailingSlash) : Promise.resolve({ broken: [], anchors: [], trailingSlash: [], scanned: { links: 0, ids: 0 } }),
801
857
  checkMdxLinks(contentDirs, rootDir, hasDist ? distDir : null, config.basePath, config.localeKeys),
802
858
  checkMdxAnchors(contentDirs, rootDir, config.basePath, config.localeKeys, excludePatterns),
803
859
  ]);
@@ -810,6 +866,7 @@ async function main() {
810
866
  const realAnchors = filter(anchorWarnings);
811
867
  const realTrailing = filter(trailingSlash);
812
868
  console.log(formatReport(broken, mdxWarnings, trailingSlash, anchorWarnings));
869
+ if (hasDist) console.log(`\nBuilt HTML scan: ${scanned.links} internal link${scanned.links === 1 ? "" : "s"} and ${scanned.ids} ID attribute${scanned.ids === 1 ? "" : "s"} inspected.`);
813
870
  const skipped = broken.length - realBroken.length + mdxWarnings.length - realAbsolute.length + anchorWarnings.length - realAnchors.length + trailingSlash.length - realTrailing.length;
814
871
  if (skipped > 0) console.log(`\nAllowlist: ${skipped} known exception${skipped === 1 ? "" : "s"} excluded from strict-mode counts (${allowlistPath}).`);
815
872
  let failed = false;
@@ -1,8 +1,8 @@
1
1
  /** @jsxRuntime automatic */
2
2
  /** @jsxImportSource preact */
3
3
  // Locked manifest (#2653 Decision 4, i18n addendum): the locale-prefixed
4
- // counterpart of pages/docs/[[...slug]].tsx required for the same reason
5
- // (injected DYNAMIC routes 404 in `zfb dev`). Self-contained: only the
4
+ // counterpart of pages/docs/[[...slug]].tsx, retained for explicit host route
5
+ // ownership; zfb 2.13.1 also serves injected dynamic routes in dev. Self-contained: only the
6
6
  // sanctioned package entrypoints — no `pages/lib`, no `@/config`. The
7
7
  // `virtual:zudo-doc-chrome-bindings` import is unconditional, just like the
8
8
  // default-locale stub: the routes plugin supplies `{}` when no host module is