create-zudo-doc 0.1.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/LICENSE +21 -0
- package/README.md +146 -0
- package/bin/create-zudo-doc.js +2 -0
- package/dist/api.d.ts +20 -0
- package/dist/api.js +13 -0
- package/dist/claude-md-gen.d.ts +2 -0
- package/dist/claude-md-gen.js +113 -0
- package/dist/cli.d.ts +39 -0
- package/dist/cli.js +157 -0
- package/dist/compose.d.ts +95 -0
- package/dist/compose.js +206 -0
- package/dist/constants.d.ts +20 -0
- package/dist/constants.js +224 -0
- package/dist/features/body-foot-util.d.ts +10 -0
- package/dist/features/body-foot-util.js +12 -0
- package/dist/features/claude-resources.d.ts +2 -0
- package/dist/features/claude-resources.js +6 -0
- package/dist/features/design-token-panel.d.ts +14 -0
- package/dist/features/design-token-panel.js +27 -0
- package/dist/features/doc-history.d.ts +9 -0
- package/dist/features/doc-history.js +11 -0
- package/dist/features/doc-tags.d.ts +19 -0
- package/dist/features/doc-tags.js +33 -0
- package/dist/features/footer-taglist.d.ts +14 -0
- package/dist/features/footer-taglist.js +17 -0
- package/dist/features/footer.d.ts +8 -0
- package/dist/features/footer.js +10 -0
- package/dist/features/i18n.d.ts +22 -0
- package/dist/features/i18n.js +41 -0
- package/dist/features/image-enlarge.d.ts +11 -0
- package/dist/features/image-enlarge.js +13 -0
- package/dist/features/index.d.ts +15 -0
- package/dist/features/index.js +53 -0
- package/dist/features/llms-txt.d.ts +11 -0
- package/dist/features/llms-txt.js +13 -0
- package/dist/features/search.d.ts +9 -0
- package/dist/features/search.js +11 -0
- package/dist/features/sidebar-resizer.d.ts +14 -0
- package/dist/features/sidebar-resizer.js +16 -0
- package/dist/features/sidebar-toggle.d.ts +13 -0
- package/dist/features/sidebar-toggle.js +15 -0
- package/dist/features/tag-governance.d.ts +14 -0
- package/dist/features/tag-governance.js +16 -0
- package/dist/features/tauri-dev.d.ts +2 -0
- package/dist/features/tauri-dev.js +25 -0
- package/dist/features/tauri.d.ts +11 -0
- package/dist/features/tauri.js +52 -0
- package/dist/features/versioning.d.ts +27 -0
- package/dist/features/versioning.js +43 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +150 -0
- package/dist/preset.d.ts +37 -0
- package/dist/preset.js +156 -0
- package/dist/prompts.d.ts +32 -0
- package/dist/prompts.js +248 -0
- package/dist/scaffold.d.ts +4 -0
- package/dist/scaffold.js +344 -0
- package/dist/settings-gen.d.ts +2 -0
- package/dist/settings-gen.js +237 -0
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +34 -0
- package/dist/zfb-config-gen.d.ts +19 -0
- package/dist/zfb-config-gen.js +222 -0
- package/package.json +65 -0
- package/templates/base/.htmlvalidate.json +5 -0
- package/templates/base/.zfb/doc-history-meta.json +1 -0
- package/templates/base/pages/404.tsx +55 -0
- package/templates/base/pages/_data.ts +179 -0
- package/templates/base/pages/_mdx-components.ts +249 -0
- package/templates/base/pages/docs/[...slug].tsx +448 -0
- package/templates/base/pages/index.tsx +158 -0
- package/templates/base/pages/lib/_body-end-islands.tsx +201 -0
- package/templates/base/pages/lib/_category-nav.tsx +148 -0
- package/templates/base/pages/lib/_category-tree-nav.tsx +104 -0
- package/templates/base/pages/lib/_compose-meta-title.ts +29 -0
- package/templates/base/pages/lib/_details.tsx +30 -0
- package/templates/base/pages/lib/_doc-history-area.tsx +178 -0
- package/templates/base/pages/lib/_doc-metainfo-area.tsx +100 -0
- package/templates/base/pages/lib/_doc-tags-area.tsx +89 -0
- package/templates/base/pages/lib/_extract-headings.ts +81 -0
- package/templates/base/pages/lib/_footer-with-defaults.tsx +234 -0
- package/templates/base/pages/lib/_frontmatter-preview-data.ts +53 -0
- package/templates/base/pages/lib/_head-with-defaults.tsx +113 -0
- package/templates/base/pages/lib/_header-with-defaults.tsx +386 -0
- package/templates/base/pages/lib/_inline-version-switcher.tsx +84 -0
- package/templates/base/pages/lib/_math-block.tsx +63 -0
- package/templates/base/pages/lib/_nav-source-docs.ts +68 -0
- package/templates/base/pages/lib/_preset-generator.tsx +81 -0
- package/templates/base/pages/lib/_search-widget-script.ts +388 -0
- package/templates/base/pages/lib/_search-widget.tsx +196 -0
- package/templates/base/pages/lib/_sidebar-with-defaults.tsx +176 -0
- package/templates/base/pages/lib/_site-tree-nav.tsx +128 -0
- package/templates/base/pages/lib/locale-merge.ts +58 -0
- package/templates/base/pages/lib/route-enumerators.ts +302 -0
- package/templates/base/pages/sitemap.xml.tsx +51 -0
- package/templates/base/plugins/connect-adapter.mjs +144 -0
- package/templates/base/plugins/copy-public-plugin.mjs +50 -0
- package/templates/base/plugins/search-index-plugin.mjs +54 -0
- package/templates/base/scripts/run-b4push.sh +102 -0
- package/templates/base/src/components/ai-chat-modal.tsx +15 -0
- package/templates/base/src/components/client-router-bootstrap.tsx +14 -0
- package/templates/base/src/components/content/component-map.ts +25 -0
- package/templates/base/src/components/content/content-blockquote.tsx +16 -0
- package/templates/base/src/components/content/content-code.tsx +117 -0
- package/templates/base/src/components/content/content-link.tsx +83 -0
- package/templates/base/src/components/content/content-ol.tsx +19 -0
- package/templates/base/src/components/content/content-paragraph.tsx +10 -0
- package/templates/base/src/components/content/content-strong.tsx +16 -0
- package/templates/base/src/components/content/content-table.tsx +18 -0
- package/templates/base/src/components/content/content-ul.tsx +18 -0
- package/templates/base/src/components/content/heading-h2.tsx +26 -0
- package/templates/base/src/components/content/heading-h3.tsx +26 -0
- package/templates/base/src/components/content/heading-h4.tsx +26 -0
- package/templates/base/src/components/design-token-panel-bootstrap.tsx +15 -0
- package/templates/base/src/components/desktop-sidebar-toggle.tsx +15 -0
- package/templates/base/src/components/doc-history.tsx +18 -0
- package/templates/base/src/components/html-preview/highlighted-code.tsx +74 -0
- package/templates/base/src/components/html-preview/html-preview.tsx +108 -0
- package/templates/base/src/components/html-preview/preflight.ts +112 -0
- package/templates/base/src/components/html-preview/preview-base.tsx +159 -0
- package/templates/base/src/components/image-enlarge.tsx +19 -0
- package/templates/base/src/components/mobile-toc.tsx +94 -0
- package/templates/base/src/components/preset-generator.tsx +14 -0
- package/templates/base/src/components/sidebar-toggle.tsx +98 -0
- package/templates/base/src/components/sidebar-tree.tsx +543 -0
- package/templates/base/src/components/site-tree-nav.tsx +233 -0
- package/templates/base/src/components/theme-toggle.tsx +93 -0
- package/templates/base/src/components/toc.tsx +63 -0
- package/templates/base/src/components/tree-nav-shared.tsx +71 -0
- package/templates/base/src/config/color-scheme-utils.ts +182 -0
- package/templates/base/src/config/color-schemes.ts +128 -0
- package/templates/base/src/config/frontmatter-preview-defaults.ts +24 -0
- package/templates/base/src/config/frontmatter-preview-renderers.tsx +46 -0
- package/templates/base/src/config/i18n.ts +225 -0
- package/templates/base/src/config/settings-types.ts +162 -0
- package/templates/base/src/config/sidebars.ts +66 -0
- package/templates/base/src/config/tag-vocabulary-types.ts +39 -0
- package/templates/base/src/config/tag-vocabulary.ts +20 -0
- package/templates/base/src/hooks/use-active-heading.ts +133 -0
- package/templates/base/src/plugins/docs-source-map.ts +103 -0
- package/templates/base/src/plugins/hast-utils.ts +10 -0
- package/templates/base/src/plugins/rehype-code-title.ts +50 -0
- package/templates/base/src/plugins/rehype-heading-links.ts +53 -0
- package/templates/base/src/plugins/rehype-image-enlarge.ts +113 -0
- package/templates/base/src/plugins/rehype-mermaid.ts +41 -0
- package/templates/base/src/plugins/rehype-strip-md-extension.ts +58 -0
- package/templates/base/src/plugins/remark-admonitions.ts +99 -0
- package/templates/base/src/plugins/remark-resolve-markdown-links.ts +127 -0
- package/templates/base/src/plugins/url-utils.ts +4 -0
- package/templates/base/src/styles/global.css +1066 -0
- package/templates/base/src/types/docs-entry.ts +39 -0
- package/templates/base/src/types/heading.ts +5 -0
- package/templates/base/src/types/locale.ts +10 -0
- package/templates/base/src/utils/base.ts +139 -0
- package/templates/base/src/utils/content-files.ts +106 -0
- package/templates/base/src/utils/dedent.ts +24 -0
- package/templates/base/src/utils/docs.ts +335 -0
- package/templates/base/src/utils/git-info.ts +70 -0
- package/templates/base/src/utils/github.ts +19 -0
- package/templates/base/src/utils/header-right-items.ts +38 -0
- package/templates/base/src/utils/nav-scope.ts +63 -0
- package/templates/base/src/utils/sidebar.ts +104 -0
- package/templates/base/src/utils/slug.ts +10 -0
- package/templates/base/src/utils/smart-break.tsx +126 -0
- package/templates/base/src/utils/tags.ts +126 -0
- package/templates/base/tsconfig.json +36 -0
- package/templates/features/bodyFootUtil/files/src/utils/github.ts +19 -0
- package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +137 -0
- package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/escape-for-mdx.test.ts +34 -0
- package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/generate.test.ts +376 -0
- package/templates/features/claudeResources/files/src/integrations/claude-resources/escape-for-mdx.ts +93 -0
- package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +586 -0
- package/templates/features/designTokenPanel/files/src/components/design-token-panel-bootstrap.tsx +15 -0
- package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +99 -0
- package/templates/features/designTokenPanel/files/src/config/design-tokens-manifest.ts +177 -0
- package/templates/features/designTokenPanel/files/src/lib/design-token-panel-bootstrap.ts +50 -0
- package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +99 -0
- package/templates/features/docHistory/files/src/components/doc-history.tsx +598 -0
- package/templates/features/docHistory/files/src/types/doc-history.ts +23 -0
- package/templates/features/docHistory/files/src/utils/doc-history.ts +180 -0
- package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +116 -0
- package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +99 -0
- package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +101 -0
- package/templates/features/docTags/files/pages/docs/tags/index.tsx +86 -0
- package/templates/features/i18n/files/pages/[locale]/docs/[...slug].tsx +467 -0
- package/templates/features/i18n/files/pages/[locale]/index.tsx +213 -0
- package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +248 -0
- package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +74 -0
- package/templates/features/sidebarResizer/files/src/scripts/sidebar-resizer.ts +185 -0
- package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +126 -0
- package/templates/features/tagGovernance/files/scripts/tags-audit.ts +576 -0
- package/templates/features/tagGovernance/files/scripts/tags-suggest.ts +428 -0
- package/templates/features/tauri/files/src/components/find-bar.tsx +122 -0
- package/templates/features/tauri/files/src/components/find-in-page-init.tsx +53 -0
- package/templates/features/tauri/files/src/utils/find-in-page.ts +175 -0
- package/templates/features/tauri/files/src-tauri/Cargo.toml +14 -0
- package/templates/features/tauri/files/src-tauri/build.rs +3 -0
- package/templates/features/tauri/files/src-tauri/capabilities/default.json +11 -0
- package/templates/features/tauri/files/src-tauri/src/main.rs +250 -0
- package/templates/features/tauri/files/src-tauri/tauri.conf.json +25 -0
- package/templates/features/tauriDev/files/src-tauri-dev/Cargo.toml +15 -0
- package/templates/features/tauriDev/files/src-tauri-dev/build.rs +3 -0
- package/templates/features/tauriDev/files/src-tauri-dev/capabilities/default.json +7 -0
- package/templates/features/tauriDev/files/src-tauri-dev/frontend/index.html +187 -0
- package/templates/features/tauriDev/files/src-tauri-dev/icons/icon.png +0 -0
- package/templates/features/tauriDev/files/src-tauri-dev/src/main.rs +995 -0
- package/templates/features/tauriDev/files/src-tauri-dev/tauri.conf.json +22 -0
- package/templates/features/tauriDev/files/src-tauri-dev/test-launch.sh +65 -0
- package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +100 -0
- package/templates/features/versioning/files/pages/docs/versions.tsx +78 -0
- package/templates/features/versioning/files/pages/v/[version]/docs/[...slug].tsx +451 -0
- package/templates/features/versioning/files/pages/v/[version]/ja/docs/[...slug].tsx +490 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Pure URL-enumeration helpers shared by both page paths() functions and the
|
|
2
|
+
// sitemap. Extracting these prevents the sitemap from drifting out of sync
|
|
3
|
+
// with the actual routes the page modules produce.
|
|
4
|
+
//
|
|
5
|
+
// Each enumerator returns absolute paths (with settings.base prefix and
|
|
6
|
+
// trailing slash applied) as expected by the sitemap and page modules.
|
|
7
|
+
// `enumerateAllRoutes()` composes the others and returns a deduped
|
|
8
|
+
// Map<url, lastmod> that the sitemap renderer wraps directly.
|
|
9
|
+
//
|
|
10
|
+
// Design principles:
|
|
11
|
+
// - Draft pages are always excluded (never built).
|
|
12
|
+
// - Unlisted pages ARE included — they have real HTML files and should
|
|
13
|
+
// appear in the sitemap even though they're hidden from nav.
|
|
14
|
+
// - toRouteSlug() is applied to all entry ids so category index pages
|
|
15
|
+
// (e.g. "getting-started/index" → "getting-started") get correct URLs.
|
|
16
|
+
// - Auto-generated category index pages (categories without index.mdx) are
|
|
17
|
+
// emitted by building the nav tree and calling collectAutoIndexNodes.
|
|
18
|
+
|
|
19
|
+
import { loadDocs } from "../_data";
|
|
20
|
+
import { mergeLocaleDocs } from "./locale-merge";
|
|
21
|
+
import { settings } from "@/config/settings";
|
|
22
|
+
import { defaultLocale } from "@/config/i18n";
|
|
23
|
+
import type { VersionConfig } from "@/config/settings";
|
|
24
|
+
import type { DocsEntry } from "@/types/docs-entry";
|
|
25
|
+
import { docsUrl, versionedDocsUrl, withBase, isDefaultLocaleOnlyPath } from "@/utils/base";
|
|
26
|
+
import { collectTags } from "@/utils/tags";
|
|
27
|
+
import { toRouteSlug } from "@/utils/slug";
|
|
28
|
+
import {
|
|
29
|
+
buildNavTree,
|
|
30
|
+
loadCategoryMeta,
|
|
31
|
+
collectAutoIndexNodes,
|
|
32
|
+
isNavVisible,
|
|
33
|
+
} from "@/utils/docs";
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// enumerateDocsRoutes
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Enumerate all doc page URLs for a locale.
|
|
41
|
+
*
|
|
42
|
+
* For the default locale: loads the "docs" collection directly.
|
|
43
|
+
* For non-default locales: inlines a locale-first merge — locale docs take
|
|
44
|
+
* priority; base EN docs fill in slugs not covered by the locale collection,
|
|
45
|
+
* with default-locale-only paths excluded. A nav-tree pass then adds
|
|
46
|
+
* auto-generated category index pages.
|
|
47
|
+
*
|
|
48
|
+
* Applies toRouteSlug so "category/index" entries become "category/" URLs.
|
|
49
|
+
* Returns deduplicated URL strings with base prefix and trailing slash.
|
|
50
|
+
*/
|
|
51
|
+
export function enumerateDocsRoutes(locale: string): string[] {
|
|
52
|
+
const urls: string[] = [];
|
|
53
|
+
|
|
54
|
+
if (locale === defaultLocale) {
|
|
55
|
+
const allDocs = loadDocs("docs").filter((d) => !d.data.draft);
|
|
56
|
+
const categoryMeta = loadCategoryMeta(settings.docsDir);
|
|
57
|
+
const navDocs = allDocs.filter(isNavVisible);
|
|
58
|
+
const tree = buildNavTree(navDocs, locale, categoryMeta);
|
|
59
|
+
|
|
60
|
+
for (const doc of allDocs) {
|
|
61
|
+
urls.push(docsUrl(doc.data.slug ?? toRouteSlug(doc.id), locale as string));
|
|
62
|
+
}
|
|
63
|
+
for (const node of collectAutoIndexNodes(tree)) {
|
|
64
|
+
urls.push(docsUrl(node.slug, locale as string));
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
const localeDocs = loadDocs(`docs-${locale}`).filter((d) => !d.data.draft);
|
|
68
|
+
const baseDocs = loadDocs("docs").filter((d) => !d.data.draft);
|
|
69
|
+
const localeSlugSet = new Set(localeDocs.map((d) => d.data.slug ?? d.id));
|
|
70
|
+
const fallbackDocs = baseDocs.filter(
|
|
71
|
+
(d) => !localeSlugSet.has(d.data.slug ?? d.id) && !isDefaultLocaleOnlyPath(`/docs/${d.data.slug ?? d.id}`),
|
|
72
|
+
);
|
|
73
|
+
const allDocs = [...localeDocs, ...fallbackDocs] as DocsEntry[];
|
|
74
|
+
|
|
75
|
+
for (const doc of allDocs) {
|
|
76
|
+
urls.push(docsUrl(doc.data.slug ?? doc.id, locale as string));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const localeConfig = (
|
|
80
|
+
settings.locales as Record<string, { dir: string }>
|
|
81
|
+
)[locale];
|
|
82
|
+
const contentDir = localeConfig?.dir ?? settings.docsDir;
|
|
83
|
+
const categoryMeta = new Map([
|
|
84
|
+
...loadCategoryMeta(settings.docsDir),
|
|
85
|
+
...loadCategoryMeta(contentDir),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const navDocs = allDocs.filter(isNavVisible);
|
|
89
|
+
const tree = buildNavTree(navDocs, locale, categoryMeta);
|
|
90
|
+
for (const node of collectAutoIndexNodes(tree)) {
|
|
91
|
+
urls.push(docsUrl(node.slug, locale as string));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [...new Set(urls)];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// enumerateTagsRoutes
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Enumerate tag-index and per-tag URLs for a locale.
|
|
104
|
+
*
|
|
105
|
+
* Uses the same tag map as the tag pages (unlisted + draft excluded) so the
|
|
106
|
+
* sitemap lists exactly the same tag pages that get built.
|
|
107
|
+
*
|
|
108
|
+
* Returns:
|
|
109
|
+
* - /docs/tags/ (or /{locale}/docs/tags/)
|
|
110
|
+
* - /docs/tags/{tag}/ (or /{locale}/docs/tags/{tag}/) for each unique tag
|
|
111
|
+
*/
|
|
112
|
+
export function enumerateTagsRoutes(locale: string): string[] {
|
|
113
|
+
if (!settings.docTags) return [];
|
|
114
|
+
|
|
115
|
+
const urls: string[] = [];
|
|
116
|
+
|
|
117
|
+
const tagsBase =
|
|
118
|
+
locale === defaultLocale ? "/docs/tags" : `/${locale}/docs/tags`;
|
|
119
|
+
urls.push(withBase(tagsBase));
|
|
120
|
+
|
|
121
|
+
// Collect tags from the same merged doc set the tag pages use.
|
|
122
|
+
// mergeLocaleDocs (locale-merge.ts) filters unlisted + draft — mirrors
|
|
123
|
+
// the tag [tag].tsx pages which do the same filter.
|
|
124
|
+
let docs: DocsEntry[];
|
|
125
|
+
if (locale === defaultLocale) {
|
|
126
|
+
docs = loadDocs("docs").filter((d) => !d.data.unlisted && !d.data.draft);
|
|
127
|
+
} else {
|
|
128
|
+
docs = mergeLocaleDocs(locale);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const tagMap = collectTags(docs, (id, data) => data.slug ?? toRouteSlug(id));
|
|
132
|
+
|
|
133
|
+
for (const tag of tagMap.keys()) {
|
|
134
|
+
const tagPath =
|
|
135
|
+
locale === defaultLocale
|
|
136
|
+
? `/docs/tags/${tag}`
|
|
137
|
+
: `/${locale}/docs/tags/${tag}`;
|
|
138
|
+
urls.push(withBase(tagPath));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return urls;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// enumerateVersionedRoutes
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Enumerate doc URLs for a single (version, locale) combination.
|
|
150
|
+
*
|
|
151
|
+
* For the default locale: loads `docs-v-${version.slug}`.
|
|
152
|
+
* For non-default locales: locale-first merge — locale-specific collection
|
|
153
|
+
* takes priority; base EN collection fills in pages not yet translated.
|
|
154
|
+
* If the locale collection doesn't exist for this version, all pages fall
|
|
155
|
+
* back to the EN base (matching the page module's behaviour).
|
|
156
|
+
*
|
|
157
|
+
* Returns versioned URLs like /v/{version}/docs/{slug}/ or
|
|
158
|
+
* /v/{version}/{locale}/docs/{slug}/.
|
|
159
|
+
*/
|
|
160
|
+
export function enumerateVersionedRoutes(
|
|
161
|
+
version: VersionConfig,
|
|
162
|
+
locale: string,
|
|
163
|
+
): string[] {
|
|
164
|
+
const urls: string[] = [];
|
|
165
|
+
|
|
166
|
+
if (locale === defaultLocale) {
|
|
167
|
+
const collectionName = `docs-v-${version.slug}`;
|
|
168
|
+
const allDocs = loadDocs(collectionName).filter((d) => !d.data.draft);
|
|
169
|
+
const categoryMeta = loadCategoryMeta(version.docsDir);
|
|
170
|
+
const navDocs = allDocs.filter(isNavVisible);
|
|
171
|
+
const tree = buildNavTree(navDocs, "en", categoryMeta);
|
|
172
|
+
|
|
173
|
+
for (const doc of allDocs) {
|
|
174
|
+
const slug = doc.data.slug ?? toRouteSlug(doc.id);
|
|
175
|
+
urls.push(versionedDocsUrl(slug, version.slug));
|
|
176
|
+
}
|
|
177
|
+
for (const node of collectAutoIndexNodes(tree)) {
|
|
178
|
+
urls.push(versionedDocsUrl(node.slug, version.slug));
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
const baseCollectionName = `docs-v-${version.slug}`;
|
|
182
|
+
const localeDir = (
|
|
183
|
+
version.locales as Record<string, { dir: string }> | undefined
|
|
184
|
+
)?.[locale]?.dir;
|
|
185
|
+
const localeCollectionName = localeDir
|
|
186
|
+
? `docs-v-${version.slug}-${locale}`
|
|
187
|
+
: null;
|
|
188
|
+
|
|
189
|
+
const baseDocs = loadDocs(baseCollectionName).filter((d) => !d.data.draft);
|
|
190
|
+
const localeDocs = localeCollectionName
|
|
191
|
+
? loadDocs(localeCollectionName).filter((d) => !d.data.draft)
|
|
192
|
+
: [];
|
|
193
|
+
|
|
194
|
+
const localeSlugSet = new Set(localeDocs.map((d) => d.data.slug ?? d.id));
|
|
195
|
+
const fallbackDocs = baseDocs.filter(
|
|
196
|
+
(d) => !localeSlugSet.has(d.data.slug ?? d.id) && !isDefaultLocaleOnlyPath(`/docs/${d.data.slug ?? d.id}`),
|
|
197
|
+
);
|
|
198
|
+
const allDocs = [...localeDocs, ...fallbackDocs] as DocsEntry[];
|
|
199
|
+
|
|
200
|
+
const baseCategoryMeta = loadCategoryMeta(version.docsDir);
|
|
201
|
+
const localeCategoryMeta = localeDir
|
|
202
|
+
? loadCategoryMeta(localeDir)
|
|
203
|
+
: new Map();
|
|
204
|
+
const categoryMeta = new Map([...baseCategoryMeta, ...localeCategoryMeta]);
|
|
205
|
+
|
|
206
|
+
const navDocs = allDocs.filter(isNavVisible);
|
|
207
|
+
const tree = buildNavTree(navDocs, locale, categoryMeta);
|
|
208
|
+
|
|
209
|
+
for (const doc of allDocs) {
|
|
210
|
+
const slug = doc.data.slug ?? toRouteSlug(doc.id);
|
|
211
|
+
urls.push(versionedDocsUrl(slug, version.slug, locale as string));
|
|
212
|
+
}
|
|
213
|
+
for (const node of collectAutoIndexNodes(tree)) {
|
|
214
|
+
urls.push(versionedDocsUrl(node.slug, version.slug, locale as string));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return [...new Set(urls)];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// enumerateAllRoutes
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Compose all route enumerators into a deduped Map<url, lastmod>.
|
|
227
|
+
*
|
|
228
|
+
* Covers:
|
|
229
|
+
* - Site root
|
|
230
|
+
* - Default-locale docs + tags
|
|
231
|
+
* - Per-locale homepages, docs, and tags
|
|
232
|
+
* - Versioned EN docs (for each version in settings.versions)
|
|
233
|
+
* - Versioned locale docs (for each locale in settings.locales)
|
|
234
|
+
*
|
|
235
|
+
* The map keys are absolute paths (with settings.base prefix + trailing
|
|
236
|
+
* slash). The sitemap renderer prefixes each with settings.siteUrl.
|
|
237
|
+
*/
|
|
238
|
+
export function enumerateAllRoutes(): Map<string, string> {
|
|
239
|
+
const today = new Date().toISOString().split("T")[0];
|
|
240
|
+
const routes = new Map<string, string>();
|
|
241
|
+
|
|
242
|
+
function add(url: string): void {
|
|
243
|
+
if (!routes.has(url)) {
|
|
244
|
+
routes.set(url, today);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Site root
|
|
249
|
+
add(withBase("/"));
|
|
250
|
+
|
|
251
|
+
// Default locale docs
|
|
252
|
+
for (const url of enumerateDocsRoutes(defaultLocale)) {
|
|
253
|
+
add(url);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Default locale tags
|
|
257
|
+
for (const url of enumerateTagsRoutes(defaultLocale)) {
|
|
258
|
+
add(url);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Non-default locales
|
|
262
|
+
for (const locale of Object.keys(settings.locales)) {
|
|
263
|
+
add(withBase(`/${locale}`));
|
|
264
|
+
|
|
265
|
+
for (const url of enumerateDocsRoutes(locale)) {
|
|
266
|
+
add(url);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const url of enumerateTagsRoutes(locale)) {
|
|
270
|
+
add(url);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Versions listing pages — /docs/versions/ and /{locale}/docs/versions/.
|
|
275
|
+
// These static utility pages are built by pages/docs/versions.tsx and
|
|
276
|
+
// pages/[locale]/docs/versions.tsx whenever versioning is configured.
|
|
277
|
+
// They are not part of any content collection so they are added explicitly.
|
|
278
|
+
if (settings.versions) {
|
|
279
|
+
add(withBase("/docs/versions"));
|
|
280
|
+
for (const locale of Object.keys(settings.locales)) {
|
|
281
|
+
add(withBase(`/${locale}/docs/versions`));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Versioned docs
|
|
286
|
+
if (settings.versions) {
|
|
287
|
+
for (const version of settings.versions as VersionConfig[]) {
|
|
288
|
+
for (const url of enumerateVersionedRoutes(version, defaultLocale)) {
|
|
289
|
+
add(url);
|
|
290
|
+
}
|
|
291
|
+
// Non-default locales always have versioned pages (they fall back to EN
|
|
292
|
+
// when a locale-specific collection is not configured).
|
|
293
|
+
for (const locale of Object.keys(settings.locales)) {
|
|
294
|
+
for (const url of enumerateVersionedRoutes(version, locale)) {
|
|
295
|
+
add(url);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return routes;
|
|
302
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Port of `src/integrations/sitemap.ts` onto zfb's pages-style filename
|
|
2
|
+
// convention.
|
|
3
|
+
//
|
|
4
|
+
// Filename → output extension mapping (zfb convention): the
|
|
5
|
+
// second-to-last `.`-separated segment of the stem becomes the output
|
|
6
|
+
// extension, so `sitemap.xml.tsx` builds `dist/sitemap.xml`. The
|
|
7
|
+
// explicit `contentType` export pins the dev-server `Content-Type`
|
|
8
|
+
// header to `application/xml` regardless of the filename hint.
|
|
9
|
+
//
|
|
10
|
+
// URL enumeration is delegated to `pages/lib/route-enumerators.ts` so
|
|
11
|
+
// the sitemap cannot drift from the actual routes the page modules build.
|
|
12
|
+
// Previously the sitemap walked raw collection slugs directly and missed:
|
|
13
|
+
// (a) tag pages (b) JA fallback URLs
|
|
14
|
+
// (c) versioned JA routes (d) wrong URL pattern for versioned-locale pages
|
|
15
|
+
// (e) emitted /index/ suffix on category pages (closes #690)
|
|
16
|
+
|
|
17
|
+
import { settings } from "@/config/settings";
|
|
18
|
+
import { enumerateAllRoutes } from "./lib/route-enumerators";
|
|
19
|
+
|
|
20
|
+
export const frontmatter = { title: "Sitemap" };
|
|
21
|
+
export const contentType = "application/xml";
|
|
22
|
+
|
|
23
|
+
function escapeXml(str: string): string {
|
|
24
|
+
return str
|
|
25
|
+
.replace(/&/g, "&")
|
|
26
|
+
.replace(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">")
|
|
28
|
+
.replace(/"/g, """)
|
|
29
|
+
.replace(/'/g, "'");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function Sitemap(): string {
|
|
33
|
+
const routeMap = enumerateAllRoutes();
|
|
34
|
+
const siteUrlBase = (settings.siteUrl ?? "").replace(/\/$/, "");
|
|
35
|
+
|
|
36
|
+
const urlEntries = [...routeMap.entries()]
|
|
37
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
38
|
+
.map(
|
|
39
|
+
([url, lastmod]) => ` <url>
|
|
40
|
+
<loc>${escapeXml(siteUrlBase + url)}</loc>
|
|
41
|
+
<lastmod>${lastmod}</lastmod>
|
|
42
|
+
</url>`,
|
|
43
|
+
)
|
|
44
|
+
.join("\n");
|
|
45
|
+
|
|
46
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
47
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
48
|
+
${urlEntries}
|
|
49
|
+
</urlset>
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Adapter from Connect-style middleware (`(req, res, next) => void`) to
|
|
2
|
+
// the request-response shape zfb's `devMiddleware` lifecycle hook
|
|
3
|
+
// expects (`(req: ZfbDevMiddlewareRequest) => Promise<ZfbDevMiddlewareResponse | undefined>`).
|
|
4
|
+
//
|
|
5
|
+
// zfb's plugin host runs in a separate Node subprocess and only sees a
|
|
6
|
+
// JSON envelope of the request — `{ method, url, headers, body? }` —
|
|
7
|
+
// not real Node IPC. The adapter mocks just enough of `IncomingMessage`
|
|
8
|
+
// and `ServerResponse` for the v2 integration middlewares (which were
|
|
9
|
+
// written against Node's `http` types) to think they are talking to a
|
|
10
|
+
// regular HTTP server, then captures the response status / headers /
|
|
11
|
+
// body and returns them in the shape the host expects.
|
|
12
|
+
//
|
|
13
|
+
// The adapter is shared by every plugin module under this directory so
|
|
14
|
+
// any future Connect-style middleware can be wired into zfb's
|
|
15
|
+
// devMiddleware hook without rewriting it. Lives at the host repo —
|
|
16
|
+
// the v2 integration package keeps its Connect-style API surface so
|
|
17
|
+
// non-zfb embedders (Astro, plain Vite, a unit test) continue to work.
|
|
18
|
+
|
|
19
|
+
import { Buffer } from "node:buffer";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convert a Connect-style middleware to a zfb devMiddleware handler.
|
|
23
|
+
* The returned async function takes a `ZfbDevMiddlewareRequest` and
|
|
24
|
+
* returns either `undefined` (passthrough — zfb falls through to its
|
|
25
|
+
* built-in routes) or a `ZfbDevMiddlewareResponse` envelope.
|
|
26
|
+
*
|
|
27
|
+
* Behaviour:
|
|
28
|
+
*
|
|
29
|
+
* - `next()` from the middleware → resolves with `undefined`
|
|
30
|
+
* (passthrough).
|
|
31
|
+
* - `res.end(body)` → resolves with `{ status, headers, body }`.
|
|
32
|
+
* `status` defaults to 200 if the middleware didn't set one,
|
|
33
|
+
* mirroring Node's `ServerResponse` default.
|
|
34
|
+
* - `next(err)` or a thrown error → rejects so the host surfaces a
|
|
35
|
+
* 500 with the error message the same way it does for any other
|
|
36
|
+
* plugin throw.
|
|
37
|
+
* - Binary bodies (Buffer / Uint8Array) → encoded as base64 and
|
|
38
|
+
* flagged `bodyEncoding: "base64"` so the JSON envelope round-trip
|
|
39
|
+
* stays loss-less.
|
|
40
|
+
*/
|
|
41
|
+
export function connectToZfbHandler(middleware) {
|
|
42
|
+
return (zfbReq) => {
|
|
43
|
+
return new Promise((resolveResponse, rejectResponse) => {
|
|
44
|
+
// Build a minimal `IncomingMessage` shim. Only the fields the v2
|
|
45
|
+
// integration middlewares actually read are populated — `method`,
|
|
46
|
+
// `url`, and `headers`. Body parsing is not used by any of the
|
|
47
|
+
// three middlewares (they're all GET routes), so we leave the
|
|
48
|
+
// stream surface unimplemented.
|
|
49
|
+
const req = {
|
|
50
|
+
method: zfbReq.method,
|
|
51
|
+
url: zfbReq.url,
|
|
52
|
+
headers: zfbReq.headers ?? {},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Build a `ServerResponse` shim that captures status, headers,
|
|
56
|
+
// and body. We expose the API surface the v2 middlewares touch
|
|
57
|
+
// today (`statusCode`, `setHeader`, `getHeader`, `end`) — extend
|
|
58
|
+
// here if a future middleware needs more.
|
|
59
|
+
let statusCode = 200;
|
|
60
|
+
const headers = {};
|
|
61
|
+
let settled = false;
|
|
62
|
+
|
|
63
|
+
const finish = (body) => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
// Lower-case header names so the host's response shape matches
|
|
67
|
+
// axum's expectation (`Record<string, string>` of arbitrary
|
|
68
|
+
// case). Last-wins on collision; with `setHeader` callers this
|
|
69
|
+
// shouldn't happen.
|
|
70
|
+
const normalisedHeaders = {};
|
|
71
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
72
|
+
normalisedHeaders[k.toLowerCase()] = String(v);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
|
|
76
|
+
resolveResponse({
|
|
77
|
+
status: statusCode,
|
|
78
|
+
headers: normalisedHeaders,
|
|
79
|
+
body: Buffer.from(body).toString("base64"),
|
|
80
|
+
bodyEncoding: "base64",
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
resolveResponse({
|
|
85
|
+
status: statusCode,
|
|
86
|
+
headers: normalisedHeaders,
|
|
87
|
+
body: body == null ? "" : String(body),
|
|
88
|
+
bodyEncoding: "utf8",
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const res = {
|
|
93
|
+
get statusCode() {
|
|
94
|
+
return statusCode;
|
|
95
|
+
},
|
|
96
|
+
set statusCode(v) {
|
|
97
|
+
statusCode = v;
|
|
98
|
+
},
|
|
99
|
+
setHeader(name, value) {
|
|
100
|
+
headers[name] = value;
|
|
101
|
+
},
|
|
102
|
+
getHeader(name) {
|
|
103
|
+
// Header lookup is case-insensitive in Node's real
|
|
104
|
+
// ServerResponse — mirror that so middlewares that probe an
|
|
105
|
+
// existing header before overwriting it (`if
|
|
106
|
+
// (!res.getHeader("Content-Type"))`) keep working.
|
|
107
|
+
const lower = name.toLowerCase();
|
|
108
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
109
|
+
if (k.toLowerCase() === lower) return v;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
},
|
|
113
|
+
get headersSent() {
|
|
114
|
+
return settled;
|
|
115
|
+
},
|
|
116
|
+
end(body) {
|
|
117
|
+
finish(body);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const next = (err) => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
if (err) {
|
|
124
|
+
settled = true;
|
|
125
|
+
rejectResponse(err instanceof Error ? err : new Error(String(err)));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// Connect's `next()` with no error means "I did not handle
|
|
129
|
+
// this request". Resolve with `undefined` so zfb's host
|
|
130
|
+
// surfaces a passthrough.
|
|
131
|
+
settled = true;
|
|
132
|
+
resolveResponse(undefined);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
middleware(req, res, next);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (settled) return;
|
|
139
|
+
settled = true;
|
|
140
|
+
rejectResponse(err instanceof Error ? err : new Error(String(err)));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// zfb plugin module: copy-public.
|
|
2
|
+
//
|
|
3
|
+
// Workaround for upstream zfb gap — `zfb build` does not copy `public/`
|
|
4
|
+
// contents to `outDir`. See: zudolab/zudo-doc#1394;
|
|
5
|
+
// upstream issue: https://github.com/Takazudo/zudo-front-builder/issues/158
|
|
6
|
+
//
|
|
7
|
+
// postBuild — recursively copies `<projectRoot>/public/` directly into
|
|
8
|
+
// `<outDir>/` (FLAT, matching zfb's own dist/ convention —
|
|
9
|
+
// zfb emits dist/index.html, dist/assets/..., NOT
|
|
10
|
+
// dist/<base>/index.html). Under the Workers static assets
|
|
11
|
+
// deploy (base="/"), `dist/` is served at root directly by
|
|
12
|
+
// `wrangler deploy` — no deploy-pipeline relocation step is
|
|
13
|
+
// needed. The `base` option is intentionally unused here.
|
|
14
|
+
//
|
|
15
|
+
// Example: `public/img/logo.svg` becomes `dist/img/logo.svg`,
|
|
16
|
+
// served at `/img/logo.svg` by the Workers static asset layer.
|
|
17
|
+
//
|
|
18
|
+
// Missing or empty `public/` is treated as a no-op (no error).
|
|
19
|
+
//
|
|
20
|
+
// `options` carries `{ publicDir }` from the matching entry in
|
|
21
|
+
// `zfb.config.ts`. The `base` option is intentionally unused — see
|
|
22
|
+
// rationale above.
|
|
23
|
+
|
|
24
|
+
import { cp } from "node:fs/promises";
|
|
25
|
+
import { resolve } from "node:path";
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
name: "copy-public",
|
|
29
|
+
|
|
30
|
+
async postBuild(ctx) {
|
|
31
|
+
const { publicDir: publicDirOption } = ctx.options;
|
|
32
|
+
const publicDir = resolve(ctx.projectRoot, publicDirOption ?? "public");
|
|
33
|
+
const dest = ctx.outDir;
|
|
34
|
+
|
|
35
|
+
ctx.logger.info(`copying ${publicDir} → ${dest}`);
|
|
36
|
+
|
|
37
|
+
await cp(publicDir, dest, {
|
|
38
|
+
recursive: true,
|
|
39
|
+
force: true,
|
|
40
|
+
errorOnExist: false,
|
|
41
|
+
}).catch((err) => {
|
|
42
|
+
if (err.code === "ENOENT") {
|
|
43
|
+
// publicDir does not exist or is empty — treat as no-op.
|
|
44
|
+
ctx.logger.info("public/ not found — skipping copy");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// zfb plugin module: search-index.
|
|
2
|
+
//
|
|
3
|
+
// Wires two lifecycle hooks for the search-index integration:
|
|
4
|
+
//
|
|
5
|
+
// postBuild — invokes `emitSearchIndex` to write `dist/search-index.json`.
|
|
6
|
+
// There is no settings gate — the index is always emitted,
|
|
7
|
+
// matching the legacy Astro behaviour.
|
|
8
|
+
//
|
|
9
|
+
// devMiddleware — rebuilds the in-memory search index from disk on every
|
|
10
|
+
// request so authoring edits surface without a dev-server
|
|
11
|
+
// restart. Registered at `/search-index.json`.
|
|
12
|
+
//
|
|
13
|
+
// `options` carries `{ docsDir, locales, base }` from the matching entry
|
|
14
|
+
// in `zfb.config.ts`.
|
|
15
|
+
//
|
|
16
|
+
// Inline functions are not supported by zfb's plugin runtime; see the
|
|
17
|
+
// sibling `doc-history-plugin.mjs` for the rationale.
|
|
18
|
+
|
|
19
|
+
import { emitSearchIndex, createSearchIndexDevMiddleware } from "@takazudo/zudo-doc/integrations/search-index";
|
|
20
|
+
import { connectToZfbHandler } from "./connect-adapter.mjs";
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
name: "search-index",
|
|
24
|
+
|
|
25
|
+
postBuild(ctx) {
|
|
26
|
+
const { docsDir, locales, base } = ctx.options;
|
|
27
|
+
emitSearchIndex({
|
|
28
|
+
outDir: ctx.outDir,
|
|
29
|
+
docsDir,
|
|
30
|
+
locales,
|
|
31
|
+
base,
|
|
32
|
+
logger: ctx.logger,
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
devMiddleware(ctx) {
|
|
37
|
+
const middleware = createSearchIndexDevMiddleware(ctx.options);
|
|
38
|
+
// zfb's `register(path, handler)` matches against the FULL request
|
|
39
|
+
// URL (no base-stripping). For a non-root base (e.g. "/my-docs/"),
|
|
40
|
+
// requests arrive as `/my-docs/search-index.json`, so we register
|
|
41
|
+
// the full base-prefixed route. For base="/", the prefix is empty
|
|
42
|
+
// and the route is `/search-index.json` as expected. The v2
|
|
43
|
+
// middleware itself is base-tolerant (matches via
|
|
44
|
+
// `endsWith("/search-index.json")`), so it does not need a
|
|
45
|
+
// separate base-stripping pass.
|
|
46
|
+
const basePrefix = stripTrailingSlash(ctx.options.base ?? "");
|
|
47
|
+
ctx.register(`${basePrefix}/search-index.json`, connectToZfbHandler(middleware));
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function stripTrailingSlash(s) {
|
|
52
|
+
if (typeof s !== "string" || s.length === 0) return "";
|
|
53
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
54
|
+
}
|