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.
- package/dist/features/image-enlarge.d.ts +10 -2
- package/dist/features/image-enlarge.js +10 -2
- package/dist/scaffold.js +20 -22
- package/dist/zfb-config-gen.d.ts +16 -10
- package/dist/zfb-config-gen.js +34 -239
- package/package.json +1 -1
- package/templates/base/pages/lib/_body-end-islands.tsx +6 -13
- package/templates/base/pages/lib/_category-nav.tsx +30 -115
- package/templates/base/pages/lib/_category-tree-nav.tsx +38 -65
- package/templates/base/pages/lib/_compose-meta-title.ts +2 -6
- package/templates/base/pages/lib/_doc-body-end.tsx +3 -35
- package/templates/base/pages/lib/_doc-content-header.tsx +10 -111
- package/templates/base/pages/lib/_doc-history-area.tsx +19 -211
- package/templates/base/pages/lib/_doc-metainfo-area.tsx +10 -113
- package/templates/base/pages/lib/_doc-page-renderer.tsx +22 -183
- package/templates/base/pages/lib/_doc-page-shell.tsx +17 -251
- package/templates/base/pages/lib/_doc-pager.tsx +4 -74
- package/templates/base/pages/lib/_doc-route-entries.ts +43 -177
- package/templates/base/pages/lib/_doc-route-paths.ts +16 -101
- package/templates/base/pages/lib/_doc-tags-area.tsx +14 -77
- package/templates/base/pages/lib/_footer-with-defaults.tsx +37 -225
- package/templates/base/pages/lib/_frontmatter-preview-data.ts +5 -31
- package/templates/base/pages/lib/_head-with-defaults.tsx +13 -138
- package/templates/base/pages/lib/_header-with-defaults.tsx +24 -324
- package/templates/base/pages/lib/_inline-version-switcher.tsx +16 -78
- package/templates/base/pages/lib/_nav-data-prep.ts +32 -51
- package/templates/base/pages/lib/_nav-source-cache.ts +12 -57
- package/templates/base/pages/lib/_nav-source-docs.ts +33 -233
- package/templates/base/pages/lib/_search-widget-script.ts +2 -470
- package/templates/base/pages/lib/_search-widget.tsx +9 -195
- package/templates/base/pages/lib/_sidebar-prepaint.tsx +6 -59
- package/templates/base/pages/lib/_sidebar-with-defaults.tsx +16 -118
- package/templates/base/pages/lib/_site-tree-nav.tsx +29 -80
- package/templates/base/pages/lib/doc-page-props.ts +26 -44
- package/templates/base/pages/lib/locale-merge.ts +32 -145
- package/templates/base/pages/lib/route-enumerators.ts +52 -286
- package/templates/base/pages/robots.txt.tsx +2 -26
- package/templates/base/src/components/ai-chat-modal.tsx +9 -8
- package/templates/base/src/components/doc-history.tsx +9 -8
- package/templates/base/src/components/image-enlarge.tsx +11 -8
- package/templates/base/src/components/sidebar-toggle.tsx +6 -170
- package/templates/base/src/components/sidebar-tree.tsx +6 -548
- package/templates/base/src/components/site-tree-nav.tsx +6 -220
- package/templates/base/src/config/color-scheme-utils.ts +34 -158
- package/templates/base/src/config/i18n.ts +9 -0
- package/templates/base/src/config/z-index-tokens.ts +5 -4
- package/templates/base/src/styles/global.css +5 -579
- package/templates/base/src/utils/base.ts +1 -1
- package/templates/base/src/utils/docs.ts +47 -16
- package/templates/base/src/utils/github.ts +12 -9
- package/templates/base/src/utils/nav-scope.ts +13 -42
- package/templates/base/src/utils/sidebar.ts +18 -86
- package/templates/base/src/utils/slug.ts +10 -53
- package/templates/base/src/utils/smart-break.tsx +12 -120
- package/templates/base/src/utils/tags.ts +25 -68
- package/templates/features/bodyFootUtil/files/src/utils/github.ts +12 -9
- package/templates/features/designTokenPanel/files/src/lib/design-token-panel-bootstrap.ts +13 -39
- package/templates/features/docHistory/files/src/components/doc-history.tsx +8 -636
- package/templates/features/docHistory/files/src/types/doc-history.ts +7 -23
- package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +35 -201
- package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +8 -269
- package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +6 -99
- package/templates/features/tagGovernance/files/scripts/tags-audit.ts +67 -515
- package/templates/features/versioning/files/pages/lib/_versions-page.tsx +21 -73
- package/templates/base/plugins/connect-adapter.mjs +0 -169
- package/templates/base/plugins/search-index-plugin.mjs +0 -66
- package/templates/base/scripts/gen-z-index.mjs +0 -157
- package/templates/base/src/components/mermaid-enlarge.tsx +0 -490
- package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +0 -47
- package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +0 -111
- 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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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) =>
|
|
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
|
-
|
|
198
|
-
|
|
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 :
|
|
263
|
+
href: noPage ? undefined : buildHref("", lang),
|
|
233
264
|
hasPage: noPage !== true,
|
|
234
265
|
children: [],
|
|
235
266
|
sortOrder,
|
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
import {
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
"&": "&",
|
|
44
|
-
"<": "<",
|
|
45
|
-
">": ">",
|
|
46
|
-
'"': """,
|
|
47
|
-
"'": "'",
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
*
|
|
47
|
-
*
|
|
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)
|
|
58
|
-
|
|
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
|
-
|
|
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(
|