create-zudo-doc 0.2.21 → 0.2.22

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 (71) hide show
  1. package/dist/features/image-enlarge.d.ts +10 -2
  2. package/dist/features/image-enlarge.js +10 -2
  3. package/dist/scaffold.js +20 -22
  4. package/dist/zfb-config-gen.d.ts +16 -10
  5. package/dist/zfb-config-gen.js +34 -239
  6. package/package.json +1 -1
  7. package/templates/base/pages/lib/_body-end-islands.tsx +6 -13
  8. package/templates/base/pages/lib/_category-nav.tsx +30 -115
  9. package/templates/base/pages/lib/_category-tree-nav.tsx +38 -65
  10. package/templates/base/pages/lib/_compose-meta-title.ts +2 -6
  11. package/templates/base/pages/lib/_doc-body-end.tsx +3 -35
  12. package/templates/base/pages/lib/_doc-content-header.tsx +10 -111
  13. package/templates/base/pages/lib/_doc-history-area.tsx +19 -211
  14. package/templates/base/pages/lib/_doc-metainfo-area.tsx +10 -113
  15. package/templates/base/pages/lib/_doc-page-renderer.tsx +22 -183
  16. package/templates/base/pages/lib/_doc-page-shell.tsx +17 -251
  17. package/templates/base/pages/lib/_doc-pager.tsx +4 -74
  18. package/templates/base/pages/lib/_doc-route-entries.ts +43 -177
  19. package/templates/base/pages/lib/_doc-route-paths.ts +16 -101
  20. package/templates/base/pages/lib/_doc-tags-area.tsx +14 -77
  21. package/templates/base/pages/lib/_footer-with-defaults.tsx +37 -225
  22. package/templates/base/pages/lib/_frontmatter-preview-data.ts +5 -31
  23. package/templates/base/pages/lib/_head-with-defaults.tsx +13 -138
  24. package/templates/base/pages/lib/_header-with-defaults.tsx +24 -324
  25. package/templates/base/pages/lib/_inline-version-switcher.tsx +16 -78
  26. package/templates/base/pages/lib/_nav-data-prep.ts +32 -51
  27. package/templates/base/pages/lib/_nav-source-cache.ts +12 -57
  28. package/templates/base/pages/lib/_nav-source-docs.ts +33 -233
  29. package/templates/base/pages/lib/_search-widget-script.ts +2 -470
  30. package/templates/base/pages/lib/_search-widget.tsx +9 -195
  31. package/templates/base/pages/lib/_sidebar-prepaint.tsx +6 -59
  32. package/templates/base/pages/lib/_sidebar-with-defaults.tsx +16 -118
  33. package/templates/base/pages/lib/_site-tree-nav.tsx +29 -80
  34. package/templates/base/pages/lib/doc-page-props.ts +26 -44
  35. package/templates/base/pages/lib/locale-merge.ts +32 -145
  36. package/templates/base/pages/lib/route-enumerators.ts +52 -286
  37. package/templates/base/pages/robots.txt.tsx +2 -26
  38. package/templates/base/src/components/ai-chat-modal.tsx +9 -8
  39. package/templates/base/src/components/doc-history.tsx +9 -8
  40. package/templates/base/src/components/image-enlarge.tsx +11 -8
  41. package/templates/base/src/components/sidebar-toggle.tsx +6 -170
  42. package/templates/base/src/components/sidebar-tree.tsx +6 -548
  43. package/templates/base/src/components/site-tree-nav.tsx +6 -220
  44. package/templates/base/src/config/color-scheme-utils.ts +34 -158
  45. package/templates/base/src/config/i18n.ts +9 -0
  46. package/templates/base/src/config/z-index-tokens.ts +5 -4
  47. package/templates/base/src/styles/global.css +5 -579
  48. package/templates/base/src/utils/base.ts +1 -1
  49. package/templates/base/src/utils/docs.ts +47 -16
  50. package/templates/base/src/utils/github.ts +12 -9
  51. package/templates/base/src/utils/nav-scope.ts +13 -42
  52. package/templates/base/src/utils/sidebar.ts +18 -86
  53. package/templates/base/src/utils/slug.ts +10 -53
  54. package/templates/base/src/utils/smart-break.tsx +12 -120
  55. package/templates/base/src/utils/tags.ts +25 -68
  56. package/templates/features/bodyFootUtil/files/src/utils/github.ts +12 -9
  57. package/templates/features/designTokenPanel/files/src/lib/design-token-panel-bootstrap.ts +13 -39
  58. package/templates/features/docHistory/files/src/components/doc-history.tsx +8 -636
  59. package/templates/features/docHistory/files/src/types/doc-history.ts +7 -23
  60. package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +35 -201
  61. package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +8 -269
  62. package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +6 -99
  63. package/templates/features/tagGovernance/files/scripts/tags-audit.ts +67 -515
  64. package/templates/features/versioning/files/pages/lib/_versions-page.tsx +21 -73
  65. package/templates/base/plugins/connect-adapter.mjs +0 -169
  66. package/templates/base/plugins/search-index-plugin.mjs +0 -66
  67. package/templates/base/scripts/gen-z-index.mjs +0 -157
  68. package/templates/base/src/components/mermaid-enlarge.tsx +0 -490
  69. package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +0 -47
  70. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +0 -111
  71. package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +0 -93
@@ -123,6 +123,17 @@ function navTreeCacheKey(
123
123
  .join(",")}`;
124
124
  }
125
125
 
126
+ /** A docs-href builder: `(slug, locale) => url`. Lets a caller inject the URL
127
+ * space the tree's hrefs are minted in (e.g. a versioned route's own URLs)
128
+ * instead of the default latest-route `docsUrl`. */
129
+ export type BuildHref = (slug: string, locale: Locale) => string;
130
+
131
+ /** Optional injected dependencies for {@link buildNavTree}. */
132
+ export interface BuildNavTreeOptions {
133
+ /** Override the href builder used for every node (defaults to `docsUrl`). */
134
+ buildHref?: BuildHref;
135
+ }
136
+
126
137
  /**
127
138
  * Build a recursive navigation tree from a flat content collection.
128
139
  * Mirrors the filesystem: directories become category nodes, files become leaves.
@@ -131,28 +142,45 @@ function navTreeCacheKey(
131
142
  * framework builder (`buildSidebarTree` in @takazudo/zudo-doc/sidebar-tree);
132
143
  * this function keeps the host-side concerns: the NavNode shape consumed by
133
144
  * every host nav surface, the content-key cache, and the identity fast-path.
145
+ *
146
+ * `options.buildHref` parameterizes the href space (#2344, S1a): callers pass it
147
+ * to mint hrefs in their own route's URL space. Omit it to keep the default
148
+ * latest-route `docsUrl` behavior — every existing 3-arg call site is unchanged.
134
149
  */
135
150
  export function buildNavTree(
136
151
  docs: DocsEntry[],
137
152
  lang: Locale = defaultLocale,
138
153
  categoryMeta?: Map<string, CategoryMeta>,
154
+ options?: BuildNavTreeOptions,
139
155
  ): NavNode[] {
156
+ const buildHref: BuildHref = options?.buildHref ?? ((slug, locale) => docsUrl(slug, locale));
157
+ // Both cache layers are keyed by (docs, lang, categoryMeta) and assume the
158
+ // default `docsUrl` href space. A custom `buildHref` mints a DIFFERENT href
159
+ // space for the same key, so it must skip both the read (would return a tree
160
+ // with default hrefs) and the write (would poison the default-href cache for
161
+ // later callers). Only the default-href path — every current call site — is
162
+ // cached; custom-href callers (versioned routes, S6/S7/S8) recompute. The
163
+ // cache exists for the hot default path, which this preserves exactly.
164
+ const useCache = options?.buildHref === undefined;
165
+
140
166
  // Identity fast-path: stable array instance already seen for this
141
167
  // (lang, categoryMeta)? Return its tree without recomputing the key.
142
- const byIdentity = navTreeByIdentity.get(docs);
143
- if (byIdentity) {
144
- for (const slot of byIdentity) {
145
- if (slot.lang === lang && slot.categoryMeta === categoryMeta) {
146
- return slot.tree;
168
+ if (useCache) {
169
+ const byIdentity = navTreeByIdentity.get(docs);
170
+ if (byIdentity) {
171
+ for (const slot of byIdentity) {
172
+ if (slot.lang === lang && slot.categoryMeta === categoryMeta) {
173
+ return slot.tree;
174
+ }
147
175
  }
148
176
  }
149
- }
150
177
 
151
- const cacheKey = navTreeCacheKey(docs, lang, categoryMeta);
152
- const cached = navTreeCacheGet(cacheKey);
153
- if (cached) {
154
- rememberIdentity(docs, lang, categoryMeta, cached);
155
- return cached;
178
+ const cacheKey = navTreeCacheKey(docs, lang, categoryMeta);
179
+ const cached = navTreeCacheGet(cacheKey);
180
+ if (cached) {
181
+ rememberIdentity(docs, lang, categoryMeta, cached);
182
+ return cached;
183
+ }
156
184
  }
157
185
 
158
186
  const sidebarTree = buildSidebarTree(
@@ -166,7 +194,7 @@ export function buildNavTree(
166
194
  lang,
167
195
  {
168
196
  categoryMeta,
169
- buildHref: (slug, locale) => docsUrl(slug, locale),
197
+ buildHref: (slug, locale) => buildHref(slug, locale as Locale),
170
198
  // Host call sites own visibility: nav surfaces pre-filter via
171
199
  // `stableNavDocs(docs.filter(isNavVisible))`, while the breadcrumb tree
172
200
  // intentionally builds from the UNFILTERED list so unlisted pages still
@@ -186,7 +214,7 @@ export function buildNavTree(
186
214
  // already-sorted rest).
187
215
  const rootDoc = findRootIndexDoc(docs);
188
216
  if (rootDoc) {
189
- result.push(toRootNavNode(rootDoc, lang, categoryMeta));
217
+ result.push(toRootNavNode(rootDoc, lang, buildHref, categoryMeta));
190
218
  result.sort((a, b) => {
191
219
  const posCompare = a.position - b.position;
192
220
  if (posCompare !== 0) return posCompare;
@@ -194,8 +222,10 @@ export function buildNavTree(
194
222
  });
195
223
  }
196
224
 
197
- navTreeCacheSet(cacheKey, result);
198
- rememberIdentity(docs, lang, categoryMeta, result);
225
+ if (useCache) {
226
+ navTreeCacheSet(navTreeCacheKey(docs, lang, categoryMeta), result);
227
+ rememberIdentity(docs, lang, categoryMeta, result);
228
+ }
199
229
  return result;
200
230
  }
201
231
 
@@ -219,6 +249,7 @@ function findRootIndexDoc(docs: DocsEntry[]): DocsEntry | undefined {
219
249
  function toRootNavNode(
220
250
  doc: DocsEntry,
221
251
  lang: Locale,
252
+ buildHref: BuildHref,
222
253
  categoryMeta?: Map<string, CategoryMeta>,
223
254
  ): NavNode {
224
255
  const meta = categoryMeta?.get("");
@@ -229,7 +260,7 @@ function toRootNavNode(
229
260
  label: doc.data.sidebar_label ?? doc.data.title ?? meta?.label ?? "",
230
261
  description: doc.data.description ?? meta?.description,
231
262
  position: doc.data.sidebar_position ?? meta?.position ?? 999,
232
- href: noPage ? undefined : docsUrl("", lang),
263
+ href: noPage ? undefined : buildHref("", lang),
233
264
  hasPage: noPage !== true,
234
265
  children: [],
235
266
  sortOrder,
@@ -1,19 +1,22 @@
1
- import { settings } from "@/config/settings";
1
+ // Host thin-stub see @takazudo/zudo-doc/github-helpers (epic #2344, S7).
2
+ //
3
+ // `buildGitHubRepoUrl` and `buildGitHubSourceUrl` are now parameterized pure
4
+ // functions in the package; this module wraps them with the host's
5
+ // `settings.githubUrl` singleton.
2
6
 
3
- function trimTrailingSlash(url: string): string {
4
- return url.replace(/\/+$/, "");
5
- }
7
+ import { settings } from "@/config/settings";
8
+ import {
9
+ buildGitHubRepoUrl as _buildGitHubRepoUrl,
10
+ buildGitHubSourceUrl as _buildGitHubSourceUrl,
11
+ } from "@takazudo/zudo-doc/github-helpers";
6
12
 
7
13
  export function buildGitHubRepoUrl(): string | null {
8
- if (!settings.githubUrl) return null;
9
- return trimTrailingSlash(settings.githubUrl as string);
14
+ return _buildGitHubRepoUrl(settings.githubUrl);
10
15
  }
11
16
 
12
17
  export function buildGitHubSourceUrl(
13
18
  contentDir: string,
14
19
  entryId: string,
15
20
  ): string | null {
16
- const repoUrl = buildGitHubRepoUrl();
17
- if (!repoUrl) return null;
18
- return `${repoUrl}/blob/HEAD/${contentDir}/${entryId}`;
21
+ return _buildGitHubSourceUrl(settings.githubUrl, contentDir, entryId);
19
22
  }
@@ -1,63 +1,34 @@
1
+ // Host thin-stub for nav-scope utilities (epic #2344, S5).
2
+ // Core logic lives in @takazudo/zudo-doc/nav-scope; this stub wires
3
+ // the host's `settings.headerNav` singleton.
1
4
  import { settings } from "@/config/settings";
2
5
  import type { NavNode } from "@/utils/docs";
3
6
  export type { HeaderNavItem } from "@/config/settings";
4
7
 
8
+ import {
9
+ getCategoryOrder as _getCategoryOrder,
10
+ getNavSectionForSlug as _getNavSectionForSlug,
11
+ getNavSubtree as _getNavSubtree,
12
+ } from "@takazudo/zudo-doc/nav-scope";
13
+
5
14
  /** Collect all categoryMatch strings from headerNav, including children (ordered). */
6
15
  export function getCategoryOrder(): string[] {
7
- return settings.headerNav.flatMap((item) => {
8
- const matches: string[] = [];
9
- if (item.categoryMatch) matches.push(item.categoryMatch);
10
- if (item.children) {
11
- for (const child of item.children) {
12
- if (child.categoryMatch) matches.push(child.categoryMatch);
13
- }
14
- }
15
- return matches;
16
- });
16
+ return _getCategoryOrder(settings.headerNav);
17
17
  }
18
18
 
19
19
  /**
20
- * Given a doc's slug (e.g. "getting-started/introduction" or "claude-agents/doc-reviewer"),
21
- * return the categoryMatch value of the headerNav item it belongs to.
20
+ * Given a doc's slug, return the categoryMatch value of the headerNav item it belongs to.
22
21
  */
23
22
  export function getNavSectionForSlug(slug: string): string | undefined {
24
- const topCategory = slug.split("/")[0] ?? "";
25
- const all = getCategoryOrder();
26
-
27
- // First pass: find explicit matchers (not "!")
28
- const explicitMatches = all.filter(
29
- (cm) => cm !== "!" && topCategory.startsWith(cm),
30
- );
31
- if (explicitMatches.length > 0) {
32
- // Longest prefix match wins
33
- return explicitMatches.sort((a, b) => b.length - a.length)[0];
34
- }
35
-
36
- // Second pass: return the default ("!") matcher
37
- const defaultItem = settings.headerNav.find(
38
- (item) => item.categoryMatch === "!",
39
- );
40
- return defaultItem?.categoryMatch;
23
+ return _getNavSectionForSlug(slug, settings.headerNav);
41
24
  }
42
25
 
43
26
  /**
44
27
  * Filter top-level NavNodes by a headerNav categoryMatch value.
45
- * - "!" means everything NOT claimed by explicit matchers
46
- * - "claude" means nodes whose slug starts with "claude"
47
- * - undefined means all nodes
48
28
  */
49
29
  export function getNavSubtree(
50
30
  tree: NavNode[],
51
31
  categoryMatch?: string,
52
32
  ): NavNode[] {
53
- if (!categoryMatch) return tree;
54
-
55
- if (categoryMatch === "!") {
56
- const explicitPrefixes = getCategoryOrder().filter((cm) => cm !== "!");
57
- return tree.filter(
58
- (node) => !explicitPrefixes.some((prefix) => node.slug.startsWith(prefix)),
59
- );
60
- }
61
-
62
- return tree.filter((node) => node.slug.startsWith(categoryMatch));
33
+ return _getNavSubtree(tree, categoryMatch, settings.headerNav);
63
34
  }
@@ -1,10 +1,16 @@
1
+ // Host thin-stub for sidebar utilities (epic #2344, S5).
2
+ // Core logic lives in @takazudo/zudo-doc/sidebar-utils; this stub wires
3
+ // the host's singletons (sidebars config, getCategoryOrder for the "!" filter).
1
4
  import sidebars from "@/config/sidebars";
2
5
  import type { SidebarItem } from "@/config/sidebars";
3
6
  import type { NavNode, CategoryMeta } from "@/utils/docs";
4
- import { buildNavTree, findNode } from "@/utils/docs";
5
- import { getNavSubtree } from "@/utils/nav-scope";
7
+ import { buildNavTree } from "@/utils/docs";
8
+ import { getCategoryOrder } from "@/utils/nav-scope";
6
9
  import type { Locale } from "@/config/i18n";
7
10
  import type { DocsEntry } from "@/types/docs-entry";
11
+ import { buildSidebarForSection as _buildSidebarForSection } from "@takazudo/zudo-doc/sidebar-utils";
12
+
13
+ export type { SidebarItem };
8
14
 
9
15
  /**
10
16
  * Build sidebar nodes for a given nav section.
@@ -17,88 +23,14 @@ export function buildSidebarForSection(
17
23
  categoryMatch?: string,
18
24
  categoryMeta?: Map<string, CategoryMeta>,
19
25
  ): NavNode[] {
20
- const tree = buildNavTree(docs, lang, categoryMeta);
21
-
22
- // Check if there's a sidebar config for this section
23
- const sectionKey = categoryMatch ?? "default";
24
- const config = sidebars[sectionKey];
25
-
26
- if (!config || config.length === 0) {
27
- // Fall back to auto-generated
28
- return getNavSubtree(tree, categoryMatch);
29
- }
30
-
31
- // Resolve config to nodes
32
- return resolveItems(config, tree, lang);
33
- }
34
-
35
- function resolveItems(
36
- items: SidebarItem[],
37
- tree: NavNode[],
38
- lang: Locale,
39
- ): NavNode[] {
40
- return items.flatMap((item) => resolveItem(item, tree, lang));
41
- }
42
-
43
- function resolveItem(
44
- item: SidebarItem,
45
- tree: NavNode[],
46
- lang: Locale,
47
- ): NavNode[] {
48
- if (typeof item === "string") {
49
- // String shorthand = doc reference (always rendered as leaf, children stripped)
50
- const node = findNode(tree, item);
51
- return node ? [{ ...node, children: [] }] : [];
52
- }
53
-
54
- switch (item.type) {
55
- case "doc": {
56
- const node = findNode(tree, item.id);
57
- if (!node) return [];
58
- // Doc references always render as leaves — strip children so they
59
- // don't duplicate structure that the explicit config already defines.
60
- return [{ ...node, children: [], ...(item.label ? { label: item.label } : {}) }];
61
- }
62
- case "link": {
63
- return [
64
- {
65
- slug: `__link__${item.href}`,
66
- label: item.label,
67
- href: item.href,
68
- position: 0,
69
- hasPage: false,
70
- children: [],
71
- },
72
- ];
73
- }
74
- case "category": {
75
- const children = resolveItems(item.items, tree, lang);
76
- if (item.sortOrder === "desc") {
77
- children.reverse();
78
- }
79
- return [
80
- {
81
- slug: `__category__${item.label}`,
82
- label: item.label,
83
- position: 0,
84
- hasPage: false,
85
- href: undefined,
86
- children,
87
- sortOrder: item.sortOrder,
88
- collapsed: item.collapsed,
89
- },
90
- ];
91
- }
92
- case "autogenerated": {
93
- if (item.dirName) {
94
- const node = findNode(tree, item.dirName);
95
- return node ? node.children : [];
96
- }
97
- return tree;
98
- }
99
- default: {
100
- const _exhaustive: never = item;
101
- throw new Error(`Unhandled sidebar item type: ${JSON.stringify(_exhaustive)}`);
102
- }
103
- }
26
+ const explicitPrefixes = getCategoryOrder().filter((cm) => cm !== "!");
27
+ return _buildSidebarForSection(
28
+ docs,
29
+ lang,
30
+ categoryMatch,
31
+ categoryMeta,
32
+ sidebars,
33
+ (d, l, meta) => buildNavTree(d as DocsEntry[], l as Locale, meta as Map<string, CategoryMeta>),
34
+ explicitPrefixes,
35
+ ) as NavNode[];
104
36
  }
@@ -1,53 +1,10 @@
1
- // Canonical root-slug rule (zudolab/zudo-doc#1891, closes #1873).
2
- //
3
- // A doc collection's bare root `index.mdx` must resolve to the canonical
4
- // URL `/docs/` i.e. an EMPTY route slug `""` NOT `/docs/index/`.
5
- // Five independent index-stripping sites historically disagreed on what a
6
- // bare root `index` becomes; this helper is the single source of truth they
7
- // all route through (the standalone doc-history-server package duplicates the
8
- // one-liner with a pointer comment back here — it cannot import across the
9
- // package boundary; see its CLAUDE.md).
10
- //
11
- // Rule:
12
- // - bare root "index" → "" (URL /docs/)
13
- // - nested "x/index" → "x" (URL /docs/x/)
14
- // - everything else → unchanged
15
- //
16
- // doc-history storage uses the OPPOSITE convention: the per-page JSON and the
17
- // build-time meta manifest key the root entry under "index" (an empty path
18
- // segment is unroutable — the server regex /^\/doc-history\/(.+)\.json$/ and
19
- // the `<locale>/<slug>.json` composition both reject ""). `toHistorySlug`
20
- // applies the `"" -> "index"` sentinel so the doc-history client fetch path
21
- // and the meta-manifest lookup land on the stored key. See `toHistorySlug`.
22
- export function toRouteSlug(id: string): string {
23
- if (id === "index") return "";
24
- return id.replace(/\/index$/, "");
25
- }
26
-
27
- // Doc-history storage sentinel — the inverse of the bare-root case of
28
- // `toRouteSlug`. The canonical route slug for a root index is "" (→ /docs/),
29
- // but doc-history JSON is stored/served under "index" (an empty path segment
30
- // is unroutable). Apply this to the route slug BEFORE composing the
31
- // doc-history fetch path and the meta-manifest key so root pages resolve to
32
- // e.g. /doc-history/index.json and /doc-history/<locale>/index.json.
33
- // Non-empty slugs pass through unchanged.
34
- export function toHistorySlug(routeSlug: string): string {
35
- return routeSlug === "" ? "index" : routeSlug;
36
- }
37
-
38
- // Convert a canonical route slug into the `params.slug` array zfb's
39
- // optional-catchall route (`[[...slug]]`) expects. The bare root ("") maps to
40
- // `[]` (zero segments → /docs/); a naive `"".split("/")` yields `[""]`, which
41
- // zfb's catchall router REJECTS (empty array element), silently dropping the
42
- // entire route. Every `.split("/")` at a docs route's paths() site must go
43
- // through this helper.
44
- export function toSlugParams(routeSlug: string): string[] {
45
- return routeSlug === "" ? [] : routeSlug.split("/");
46
- }
47
-
48
- export function toTitleCase(str: string): string {
49
- return str
50
- .split("-")
51
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
52
- .join(" ");
53
- }
1
+ // Thin re-export shim. The canonical slug rules moved into the package
2
+ // (`@takazudo/zudo-doc/slug`) as part of the package-first migration (#2344,
3
+ // S1a). Host code keeps importing from `@/utils/slug`; the implementation
4
+ // including the canonical-root rule (#1891 / #1873) lives once in the package.
5
+ export {
6
+ toRouteSlug,
7
+ toHistorySlug,
8
+ toSlugParams,
9
+ toTitleCase,
10
+ } from "@takazudo/zudo-doc/slug";
@@ -1,120 +1,12 @@
1
- /** @jsxRuntime automatic */
2
- /** @jsxImportSource preact */
3
-
4
- import type { VNode } from "preact";
5
-
6
- /**
7
- * Heuristic: does `text` look like a URL, filesystem path, or similar
8
- * structure where inserting <wbr> after delimiters aids line-wrapping?
9
- *
10
- * Returns true for URLs (contain "://"), POSIX-style absolute/relative
11
- * paths ("/", "./", "../"), Windows drive paths ("C:\\..." or "C:/..."),
12
- * strings with 2+ slashes (or backslashes) between alphanumerics, and
13
- * plausible domain-plus-slash strings.
14
- *
15
- * Returns false for empty input and prose-y hyphen/slash/dot combinations
16
- * like "and/or", "well-known", "state-of-the-art", "1.2.3-beta.4", "UI/UX".
17
- */
18
- export function isPathLike(text: string): boolean {
19
- if (!text) return false;
20
- if (text.includes("://")) return true;
21
- // starts with "/", "./", or "../"
22
- if (/^\.{0,2}\//.test(text)) return true;
23
- // Windows drive letter paths, either backslash or forward slash
24
- if (/^[A-Za-z]:[\\/]/.test(text)) return true;
25
- // 2+ forward slashes appearing between alphanumeric runs
26
- const forwardMatches = text.match(/[A-Za-z0-9]\/[A-Za-z0-9]/g);
27
- if (forwardMatches && forwardMatches.length >= 2) return true;
28
- // 2+ backslashes appearing between alphanumeric runs
29
- const backMatches = text.match(/[A-Za-z0-9]\\[A-Za-z0-9]/g);
30
- if (backMatches && backMatches.length >= 2) return true;
31
- // Plausible domain-ish: a "." between alphanumerics AND at least one slash
32
- const hasDomainDot = /[A-Za-z0-9]\.[A-Za-z0-9]/.test(text);
33
- const hasSlash = /[\\/]/.test(text);
34
- if (hasDomainDot && hasSlash) return true;
35
- return false;
36
- }
37
-
38
- // Delimiter set: / \ - _ . : ? # & =
39
- // Capture in split so the delimiter is preserved at odd indices.
40
- const DELIM_SPLIT = /([/\\\-_.:?#&=])/;
41
-
42
- const HTML_ESCAPE_MAP: Record<string, string> = {
43
- "&": "&amp;",
44
- "<": "&lt;",
45
- ">": "&gt;",
46
- '"': "&quot;",
47
- "'": "&#39;",
48
- };
49
-
50
- function htmlEscape(s: string): string {
51
- return s.replace(/[&<>"']/g, (ch) => HTML_ESCAPE_MAP[ch]!);
52
- }
53
-
54
- /**
55
- * If `text` is path-like, return a Preact fragment with <wbr/> inserted
56
- * after each delimiter (/, \, -, _, ., :, ?, #, &, =). Otherwise return
57
- * the input string unchanged so callers can trust non-path prose passes
58
- * through untouched.
59
- */
60
- export function smartBreak(text: string): VNode | string {
61
- if (!isPathLike(text)) return text;
62
- const parts = text.split(DELIM_SPLIT);
63
- const nodes: (string | VNode)[] = [];
64
- for (let i = 0; i < parts.length; i++) {
65
- const part = parts[i] ?? "";
66
- if (part === "") continue;
67
- nodes.push(part);
68
- // Captured delimiter groups always land at odd indices.
69
- if (i % 2 === 1) nodes.push(<wbr key={`wbr-${i}`} />);
70
- }
71
- return <>{nodes}</>;
72
- }
73
-
74
- /**
75
- * Preact function component wrapper — pure, server-renderable.
76
- * Stringifies children and defers to smartBreak.
77
- */
78
- export function SmartBreak({ children }: { children?: unknown }): VNode {
79
- return <>{smartBreak(String(children ?? ""))}</>;
80
- }
81
-
82
- /**
83
- * HTML-escape `text` and inject a literal "<wbr>" tag after every
84
- * delimiter character. Unlike smartBreakToHtml, this does NOT check
85
- * isPathLike — it unconditionally breaks on delimiters.
86
- *
87
- * Useful when a caller has already decided that a larger string is
88
- * path-like and wants to apply the same wbr-injection rule to a
89
- * substring (e.g. a segment produced by splitting on a search-query
90
- * regex) without re-running the heuristic on fragments that are too
91
- * short to be classified correctly on their own.
92
- *
93
- * Byte-identical to smartBreakToHtml for inputs where isPathLike is
94
- * true, so the shared contract holds.
95
- */
96
- export function escapeAndInjectWbr(text: string): string {
97
- const parts = text.split(DELIM_SPLIT);
98
- let out = "";
99
- for (let i = 0; i < parts.length; i++) {
100
- const part = parts[i] ?? "";
101
- if (part === "") continue;
102
- out += htmlEscape(part);
103
- if (i % 2 === 1) out += "<wbr>";
104
- }
105
- return out;
106
- }
107
-
108
- /**
109
- * HTML-string counterpart of smartBreak. Produces a safe HTML string
110
- * with literal "<wbr>" tags injected after each delimiter in path-like
111
- * input, and HTML-escaped text elsewhere. For non-path input, returns
112
- * the HTML-escaped text unchanged (no wbr injection).
113
- *
114
- * Use this when the consumer cannot render Preact VNodes (e.g. building
115
- * an HTML string for `set:html` / dangerouslySetInnerHTML).
116
- */
117
- export function smartBreakToHtml(text: string): string {
118
- if (!isPathLike(text)) return htmlEscape(text);
119
- return escapeAndInjectWbr(text);
120
- }
1
+ // Thin re-export shim. The smart-break helpers moved into the package
2
+ // (`@takazudo/zudo-doc/smart-break`) as part of the package-first migration
3
+ // (#2344, S1a) — consolidating with the former toc-local copy. Host code keeps
4
+ // importing from `@/utils/smart-break`; the implementation lives once in the
5
+ // package.
6
+ export {
7
+ isPathLike,
8
+ smartBreak,
9
+ SmartBreak,
10
+ escapeAndInjectWbr,
11
+ smartBreakToHtml,
12
+ } from "@takazudo/zudo-doc/smart-break";
@@ -1,7 +1,23 @@
1
+ // Host thin-stub — see @takazudo/zudo-doc/tag-helpers (epic #2344, S7).
2
+ //
3
+ // `resolveTag` and `resolvePageTags` are now parameterized pure functions in the
4
+ // package; this module remains backward-compatible by wrapping them with the
5
+ // host's `settings.tagVocabulary` / `settings.tagGovernance` + the
6
+ // `tagVocabulary` entries from `@/config/tag-vocabulary`.
7
+ //
8
+ // `collectTags` stays host-side because it depends on `DocsEntry` from the
9
+ // host's content collections. It calls the local `resolveTag` wrapper which
10
+ // in turn delegates to the package.
11
+
1
12
  import type { DocsEntry } from "@/types/docs-entry";
2
13
  import { settings } from "@/config/settings";
3
14
  import { tagVocabulary } from "@/config/tag-vocabulary";
4
- import type { TagVocabularyEntry } from "@/config/tag-vocabulary-types";
15
+ import {
16
+ resolveTag as _resolveTag,
17
+ resolvePageTags as _resolvePageTags,
18
+ } from "@takazudo/zudo-doc/tag-helpers";
19
+
20
+ export type { ResolvedTag } from "@takazudo/zudo-doc/tag-helpers";
5
21
 
6
22
  export interface TagInfo {
7
23
  tag: string;
@@ -9,69 +25,19 @@ export interface TagInfo {
9
25
  docs: { slug: string; title: string; description?: string }[];
10
26
  }
11
27
 
12
- export interface ResolvedTag {
13
- /** Canonical id after alias/redirect rewrites. Equal to the raw input when unchanged. */
14
- canonical: string;
15
- /** True when the canonical id should be dropped from aggregation (deprecated without redirect). */
16
- deprecated: boolean;
17
- /** True when the raw input matched a vocabulary entry (as id or alias). */
18
- known: boolean;
19
- }
20
-
21
- interface VocabularyIndex {
22
- byId: Map<string, TagVocabularyEntry>;
23
- byAlias: Map<string, TagVocabularyEntry>;
24
- }
25
-
26
- let cachedIndex: VocabularyIndex | null = null;
27
- function getIndex(): VocabularyIndex {
28
- if (cachedIndex) return cachedIndex;
29
- const byId = new Map<string, TagVocabularyEntry>();
30
- const byAlias = new Map<string, TagVocabularyEntry>();
31
- for (const entry of tagVocabulary) {
32
- byId.set(entry.id, entry);
33
- for (const alias of entry.aliases ?? []) byAlias.set(alias, entry);
34
- }
35
- cachedIndex = { byId, byAlias };
36
- return cachedIndex;
37
- }
38
-
39
- function vocabularyActive(): boolean {
40
- return Boolean(settings.tagVocabulary) && settings.tagGovernance !== "off";
28
+ // Helpers that pass the host's settings + vocabulary entries to the package fns.
29
+ function getVocab() {
30
+ return settings.tagVocabulary ? tagVocabulary : false;
41
31
  }
42
32
 
43
33
  /**
44
34
  * Resolve a raw tag string to its canonical form.
45
35
  *
46
- * When the vocabulary is inactive (`tagVocabulary: false` or
47
- * `tagGovernance: "off"`), the raw value passes through unchanged with
48
- * `known: false, deprecated: false`. Otherwise:
49
- *
50
- * - A direct id match returns that id.
51
- * - An alias match returns the aliased entry's id.
52
- * - `deprecated: { redirect: "<id>" }` rewrites to the redirect target.
53
- * - `deprecated: true` (no redirect) returns `deprecated: true` so callers
54
- * can drop the tag from aggregation.
55
- * - An unknown value returns the raw string with `known: false`.
36
+ * Thin wrapper around `@takazudo/zudo-doc/tag-helpers` `resolveTag` that
37
+ * injects the host's tag vocabulary and governance settings.
56
38
  */
57
- export function resolveTag(raw: string): ResolvedTag {
58
- if (!vocabularyActive()) {
59
- return { canonical: raw, deprecated: false, known: false };
60
- }
61
- const { byId, byAlias } = getIndex();
62
- const entry = byId.get(raw) ?? byAlias.get(raw);
63
- if (!entry) return { canonical: raw, deprecated: false, known: false };
64
- const dep = entry.deprecated;
65
- if (dep && typeof dep === "object" && dep.redirect) {
66
- const target = byId.get(dep.redirect);
67
- if (target) return { canonical: target.id, deprecated: false, known: true };
68
- // Redirect points at a missing id — treat like plain deprecation.
69
- return { canonical: entry.id, deprecated: true, known: true };
70
- }
71
- if (dep === true) {
72
- return { canonical: entry.id, deprecated: true, known: true };
73
- }
74
- return { canonical: entry.id, deprecated: false, known: true };
39
+ export function resolveTag(raw: string) {
40
+ return _resolveTag(raw, getVocab(), settings.tagGovernance);
75
41
  }
76
42
 
77
43
  /**
@@ -80,16 +46,7 @@ export function resolveTag(raw: string): ResolvedTag {
80
46
  * produced by alias collapse are removed.
81
47
  */
82
48
  export function resolvePageTags(rawTags: readonly string[]): string[] {
83
- const out: string[] = [];
84
- const seen = new Set<string>();
85
- for (const raw of rawTags) {
86
- const { canonical, deprecated } = resolveTag(raw);
87
- if (deprecated) continue;
88
- if (seen.has(canonical)) continue;
89
- seen.add(canonical);
90
- out.push(canonical);
91
- }
92
- return out;
49
+ return _resolvePageTags(rawTags, getVocab(), settings.tagGovernance);
93
50
  }
94
51
 
95
52
  export function collectTags(