@tomehq/theme 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/chunk-37JI6XGT.js +1720 -0
- package/dist/chunk-3A2LPGUL.js +1991 -0
- package/dist/chunk-3I2QTWTW.js +1948 -0
- package/dist/chunk-45M5UIAB.js +2110 -0
- package/dist/chunk-462AGU3S.js +1959 -0
- package/dist/chunk-7MUTU5D4.js +1720 -0
- package/dist/chunk-BZGWSKT2.js +573 -0
- package/dist/chunk-CTPOZMMK.js +1703 -0
- package/dist/chunk-DO544M3G.js +1702 -0
- package/dist/chunk-DPKZBFQP.js +1777 -0
- package/dist/chunk-FWBTK5TL.js +1444 -0
- package/dist/chunk-GDQIBNX5.js +1962 -0
- package/dist/chunk-INUMUXN5.js +2095 -0
- package/dist/chunk-JA4PMX6M.js +1500 -0
- package/dist/chunk-JZRT4WNC.js +1441 -0
- package/dist/chunk-LIMYFTPC.js +1468 -0
- package/dist/chunk-MEP7P6A7.js +1500 -0
- package/dist/chunk-NOZBIES7.js +1948 -0
- package/dist/chunk-O4GH3KYX.js +1712 -0
- package/dist/chunk-OEXM3BEC.js +1702 -0
- package/dist/chunk-Q7PYTVW3.js +1771 -0
- package/dist/chunk-QCWZYABW.js +1468 -0
- package/dist/chunk-RDF25WB2.js +2085 -0
- package/dist/chunk-RKTT3ZEX.js +1500 -0
- package/dist/chunk-S47BRMNQ.js +1715 -0
- package/dist/chunk-S4ZH5F56.js +1949 -0
- package/dist/chunk-SRD7NJHS.js +1949 -0
- package/dist/chunk-TQDWPSTO.js +2087 -0
- package/dist/chunk-TTRXRPP6.js +1941 -0
- package/dist/chunk-UKYFJSUA.js +509 -0
- package/dist/chunk-VUT2FMSI.js +1937 -0
- package/dist/chunk-VVCC5JHK.js +1949 -0
- package/dist/chunk-W732TVBK.js +1944 -0
- package/dist/chunk-X4VQYPKO.js +1768 -0
- package/dist/entry.js +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/src/Shell.test.tsx +72 -0
- package/src/Shell.tsx +204 -16
- package/src/entry-helpers.test.ts +316 -0
- package/src/entry-helpers.ts +103 -0
- package/src/entry.tsx +160 -69
- package/src/routing.test.ts +124 -0
- package/src/routing.ts +45 -0
package/src/entry.tsx
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import React, { useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
3
|
import { Shell } from "./Shell.js";
|
|
4
|
+
import { pathnameToPageId as _pathnameToPageId, pageIdToPath as _pageIdToPath } from "./routing.js";
|
|
5
|
+
import {
|
|
6
|
+
loadPage,
|
|
7
|
+
computeEditUrl,
|
|
8
|
+
resolveInitialPageId,
|
|
9
|
+
detectCurrentVersion,
|
|
10
|
+
type LoadedPage,
|
|
11
|
+
} from "./entry-helpers.js";
|
|
4
12
|
|
|
5
13
|
// @ts-ignore — resolved by vite-plugin-tome
|
|
6
14
|
import config from "virtual:tome/config";
|
|
7
15
|
// @ts-ignore — resolved by vite-plugin-tome
|
|
8
|
-
import { routes, navigation } from "virtual:tome/routes";
|
|
16
|
+
import { routes, navigation, versions } from "virtual:tome/routes";
|
|
9
17
|
// @ts-ignore — resolved by vite-plugin-tome
|
|
10
18
|
import loadPageModule from "virtual:tome/page-loader";
|
|
11
19
|
// @ts-ignore — resolved by vite-plugin-tome
|
|
@@ -21,6 +29,9 @@ import {
|
|
|
21
29
|
Steps,
|
|
22
30
|
Accordion,
|
|
23
31
|
ChangelogTimeline,
|
|
32
|
+
PackageManager,
|
|
33
|
+
TypeTable,
|
|
34
|
+
FileTree,
|
|
24
35
|
} from "@tomehq/components";
|
|
25
36
|
|
|
26
37
|
const MDX_COMPONENTS: Record<string, React.ComponentType<any>> = {
|
|
@@ -31,6 +42,9 @@ const MDX_COMPONENTS: Record<string, React.ComponentType<any>> = {
|
|
|
31
42
|
Steps,
|
|
32
43
|
Accordion,
|
|
33
44
|
ChangelogTimeline,
|
|
45
|
+
PackageManager,
|
|
46
|
+
TypeTable,
|
|
47
|
+
FileTree, // Sub-components accessible as <FileTree.File /> and <FileTree.Folder /> in MDX
|
|
34
48
|
};
|
|
35
49
|
|
|
36
50
|
// ── CONTENT STYLES ───────────────────────────────────────
|
|
@@ -56,8 +70,11 @@ const contentStyles = `
|
|
|
56
70
|
.tome-content table { width: 100%; border-collapse: collapse; margin-bottom: 1em; }
|
|
57
71
|
.tome-content th, .tome-content td { padding: 0.5em 0.8em; border: 1px solid var(--bd); text-align: left; font-size: 0.9em; }
|
|
58
72
|
.tome-content th { background: var(--sf); font-weight: 600; }
|
|
59
|
-
.tome-content img { max-width: 100%; border-radius: 2px; }
|
|
73
|
+
.tome-content img { max-width: 100%; border-radius: 2px; cursor: zoom-in; }
|
|
60
74
|
.tome-content hr { border: none; border-top: 1px solid var(--bd); margin: 2em 0; }
|
|
75
|
+
.tome-mermaid { margin: 1.2em 0; text-align: center; overflow-x: auto; }
|
|
76
|
+
.tome-mermaid svg { max-width: 100%; height: auto; overflow: visible; }
|
|
77
|
+
.tome-mermaid svg .nodeLabel { overflow: visible; white-space: nowrap; }
|
|
61
78
|
|
|
62
79
|
/* Mobile responsive content */
|
|
63
80
|
@media (max-width: 767px) {
|
|
@@ -97,105 +114,176 @@ const contentStyles = `
|
|
|
97
114
|
html.dark .shiki span[style*="--shiki-dark:#6A737D"] {
|
|
98
115
|
--shiki-dark: #a0aab5 !important;
|
|
99
116
|
}
|
|
117
|
+
|
|
118
|
+
/* Light mode: darken low-contrast github-light tokens for WCAG AA on --cdBg backgrounds */
|
|
119
|
+
html:not(.dark) .shiki span[style*="color:#6A737D"] { color: #57606a !important; }
|
|
120
|
+
html:not(.dark) .shiki span[style*="color:#E36209"] { color: #b35405 !important; }
|
|
121
|
+
html:not(.dark) .shiki span[style*="color:#6F42C1"] { color: #5a32a3 !important; }
|
|
122
|
+
html:not(.dark) .shiki span[style*="color:#22863A"] { color: #1a6e2e !important; }
|
|
123
|
+
html:not(.dark) .shiki span[style*="color:#D73A49"] { color: #b62324 !important; }
|
|
124
|
+
html:not(.dark) .shiki span[style*="color:#005CC5"] { color: #0349b4 !important; }
|
|
100
125
|
`;
|
|
101
126
|
|
|
102
|
-
// ──
|
|
103
|
-
|
|
104
|
-
isMdx: false;
|
|
105
|
-
html: string;
|
|
106
|
-
frontmatter: { title: string; description?: string; toc?: boolean; type?: string };
|
|
107
|
-
headings: Array<{ depth: number; text: string; id: string }>;
|
|
108
|
-
changelogEntries?: Array<{ version: string; date?: string; url?: string; sections: Array<{ type: string; items: string[] }> }>;
|
|
109
|
-
}
|
|
127
|
+
// ── ROUTING HELPERS ──────────────────────────────────────
|
|
128
|
+
const basePath = (config.basePath || "/").replace(/\/$/, "");
|
|
110
129
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
component: React.ComponentType<{ components?: Record<string, React.ComponentType> }>;
|
|
114
|
-
frontmatter: { title: string; description?: string; toc?: boolean; type?: string };
|
|
115
|
-
headings: Array<{ depth: number; text: string; id: string }>;
|
|
130
|
+
function pathnameToPageId(pathname: string): string | null {
|
|
131
|
+
return _pathnameToPageId(pathname, basePath, routes);
|
|
116
132
|
}
|
|
117
133
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// ── PAGE LOADER ──────────────────────────────────────────
|
|
121
|
-
async function loadPage(id: string): Promise<LoadedPage | null> {
|
|
122
|
-
try {
|
|
123
|
-
const route = routes.find((r: any) => r.id === id);
|
|
124
|
-
const mod = await loadPageModule(id);
|
|
125
|
-
|
|
126
|
-
if (route?.isMdx && mod.meta) {
|
|
127
|
-
// TOM-8: MDX page — mod.default is the React component
|
|
128
|
-
return {
|
|
129
|
-
isMdx: true,
|
|
130
|
-
component: mod.default,
|
|
131
|
-
frontmatter: mod.meta.frontmatter,
|
|
132
|
-
headings: mod.meta.headings,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Regular .md page — mod.default is { html, frontmatter, headings }
|
|
137
|
-
if (!mod.default) return null;
|
|
138
|
-
|
|
139
|
-
// TOM-49: Changelog page type
|
|
140
|
-
if (mod.isChangelog && mod.changelogEntries) {
|
|
141
|
-
return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return { isMdx: false, ...mod.default };
|
|
145
|
-
} catch (err) {
|
|
146
|
-
console.error(`Failed to load page: ${id}`, err);
|
|
147
|
-
return null;
|
|
148
|
-
}
|
|
134
|
+
function pageIdToPath(id: string): string {
|
|
135
|
+
return _pageIdToPath(id, basePath, routes);
|
|
149
136
|
}
|
|
150
137
|
|
|
151
138
|
// ── APP ──────────────────────────────────────────────────
|
|
152
139
|
function App() {
|
|
153
|
-
const [currentPageId, setCurrentPageId] = useState(() =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
140
|
+
const [currentPageId, setCurrentPageId] = useState(() =>
|
|
141
|
+
resolveInitialPageId(
|
|
142
|
+
window.location.pathname,
|
|
143
|
+
window.location.hash,
|
|
144
|
+
routes,
|
|
145
|
+
basePath,
|
|
146
|
+
_pathnameToPageId,
|
|
147
|
+
)
|
|
148
|
+
);
|
|
158
149
|
|
|
159
150
|
const [pageData, setPageData] = useState<LoadedPage | null>(null);
|
|
160
151
|
const [loading, setLoading] = useState(true);
|
|
161
152
|
|
|
162
|
-
const navigateTo = useCallback(async (id: string) => {
|
|
153
|
+
const navigateTo = useCallback(async (id: string, opts?: { replace?: boolean; skipScroll?: boolean }) => {
|
|
163
154
|
setLoading(true);
|
|
164
155
|
setCurrentPageId(id);
|
|
165
|
-
|
|
166
|
-
|
|
156
|
+
const fullPath = pageIdToPath(id);
|
|
157
|
+
if (opts?.replace) {
|
|
158
|
+
window.history.replaceState(null, "", fullPath);
|
|
159
|
+
} else {
|
|
160
|
+
window.history.pushState(null, "", fullPath);
|
|
161
|
+
}
|
|
162
|
+
const data = await loadPage(id, routes, loadPageModule);
|
|
167
163
|
setPageData(data);
|
|
168
164
|
setLoading(false);
|
|
165
|
+
// Scroll to heading anchor if present, otherwise scroll to top
|
|
166
|
+
if (!opts?.skipScroll) {
|
|
167
|
+
const anchor = window.location.hash.slice(1);
|
|
168
|
+
if (anchor) {
|
|
169
|
+
requestAnimationFrame(() => {
|
|
170
|
+
const el = document.getElementById(anchor);
|
|
171
|
+
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
172
|
+
});
|
|
173
|
+
} else {
|
|
174
|
+
window.scrollTo(0, 0);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
169
177
|
}, []);
|
|
170
178
|
|
|
171
|
-
|
|
179
|
+
// Initial page load
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
// If user landed on a legacy hash URL, redirect to clean path
|
|
182
|
+
const hash = window.location.hash.slice(1);
|
|
183
|
+
if (hash && routes.some((r: any) => r.id === hash)) {
|
|
184
|
+
const fullPath = pageIdToPath(hash);
|
|
185
|
+
window.history.replaceState(null, "", fullPath);
|
|
186
|
+
navigateTo(hash, { replace: true });
|
|
187
|
+
} else {
|
|
188
|
+
navigateTo(currentPageId, { replace: true, skipScroll: true });
|
|
189
|
+
}
|
|
190
|
+
}, []);
|
|
172
191
|
|
|
192
|
+
// Listen for browser back/forward navigation
|
|
173
193
|
useEffect(() => {
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
navigateTo(hash);
|
|
194
|
+
const onPopState = () => {
|
|
195
|
+
const id = pathnameToPageId(window.location.pathname);
|
|
196
|
+
if (id && id !== currentPageId) {
|
|
197
|
+
navigateTo(id, { replace: true, skipScroll: true });
|
|
179
198
|
}
|
|
180
199
|
};
|
|
181
|
-
window.addEventListener("
|
|
182
|
-
return () => window.removeEventListener("
|
|
200
|
+
window.addEventListener("popstate", onPopState);
|
|
201
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
183
202
|
}, [currentPageId, navigateTo]);
|
|
184
203
|
|
|
204
|
+
// Mermaid diagram rendering: load from CDN and render .tome-mermaid elements
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
const els = document.querySelectorAll(".tome-mermaid[data-mermaid]");
|
|
207
|
+
if (els.length === 0) return;
|
|
208
|
+
let cancelled = false;
|
|
209
|
+
|
|
210
|
+
const MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
|
|
211
|
+
|
|
212
|
+
(async () => {
|
|
213
|
+
try {
|
|
214
|
+
// Load mermaid from CDN (ESM) — works in all browsers, no bundler dependency
|
|
215
|
+
const { default: mermaid } = await import(/* @vite-ignore */ MERMAID_CDN);
|
|
216
|
+
if (cancelled) return;
|
|
217
|
+
const isDark = document.documentElement.classList.contains("dark");
|
|
218
|
+
// Resolve CSS variable to concrete font name — mermaid can't resolve CSS vars for text measurement
|
|
219
|
+
const resolvedFont = getComputedStyle(document.documentElement).getPropertyValue("--font-body").trim() || "sans-serif";
|
|
220
|
+
mermaid.initialize({
|
|
221
|
+
startOnLoad: false,
|
|
222
|
+
theme: isDark ? "dark" : "default",
|
|
223
|
+
fontFamily: resolvedFont,
|
|
224
|
+
flowchart: { padding: 15, nodeSpacing: 30, rankSpacing: 40 },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
for (let i = 0; i < els.length; i++) {
|
|
228
|
+
const el = els[i] as HTMLElement;
|
|
229
|
+
if (el.querySelector("svg")) continue; // already rendered
|
|
230
|
+
const encoded = el.getAttribute("data-mermaid");
|
|
231
|
+
if (!encoded) continue;
|
|
232
|
+
try {
|
|
233
|
+
const code = atob(encoded);
|
|
234
|
+
const { svg } = await mermaid.render(`tome-mermaid-${i}-${Date.now()}`, code);
|
|
235
|
+
if (!cancelled) {
|
|
236
|
+
// Sanitize SVG to prevent XSS from mermaid-rendered content
|
|
237
|
+
try {
|
|
238
|
+
// @ts-ignore — CDN dynamic import for browser-only sanitization
|
|
239
|
+
const DOMPurify = (await import(/* @vite-ignore */ "https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.es.mjs")).default;
|
|
240
|
+
el.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } });
|
|
241
|
+
} catch {
|
|
242
|
+
// DOMPurify unavailable — render without sanitization (acceptable for trusted content)
|
|
243
|
+
el.innerHTML = svg;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.warn("[tome] Mermaid render failed:", err);
|
|
248
|
+
el.textContent = "Diagram rendering failed";
|
|
249
|
+
(el as HTMLElement).style.cssText = "padding:16px;color:var(--txM);font-size:13px;border:1px dashed var(--bd);border-radius:2px;text-align:center;";
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.warn("[tome] Failed to load mermaid from CDN:", err);
|
|
254
|
+
els.forEach((el) => {
|
|
255
|
+
(el as HTMLElement).textContent = "Failed to load diagram renderer";
|
|
256
|
+
(el as HTMLElement).style.cssText = "padding:16px;color:var(--txM);font-size:13px;border:1px dashed var(--bd);border-radius:2px;text-align:center;";
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
})();
|
|
260
|
+
|
|
261
|
+
return () => { cancelled = true; };
|
|
262
|
+
}, [pageData, loading]);
|
|
263
|
+
|
|
185
264
|
const allPages = routes.map((r: any) => ({
|
|
186
265
|
id: r.id,
|
|
187
266
|
title: r.frontmatter.title,
|
|
188
267
|
description: r.frontmatter.description,
|
|
189
268
|
}));
|
|
190
269
|
|
|
191
|
-
//
|
|
270
|
+
// Compute current version from route metadata
|
|
192
271
|
const currentRoute = routes.find((r: any) => r.id === currentPageId);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
272
|
+
const currentVersion = detectCurrentVersion(currentRoute, versions);
|
|
273
|
+
const editUrl = computeEditUrl(config.editLink, currentRoute?.filePath);
|
|
274
|
+
|
|
275
|
+
// KaTeX CSS: inject stylesheet when math is enabled
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
if (!(config as any).math) return;
|
|
278
|
+
const id = "tome-katex-css";
|
|
279
|
+
if (document.getElementById(id)) return;
|
|
280
|
+
const link = document.createElement("link");
|
|
281
|
+
link.id = id;
|
|
282
|
+
link.rel = "stylesheet";
|
|
283
|
+
link.href = "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css";
|
|
284
|
+
link.crossOrigin = "anonymous";
|
|
285
|
+
document.head.appendChild(link);
|
|
286
|
+
}, []);
|
|
199
287
|
|
|
200
288
|
return (
|
|
201
289
|
<>
|
|
@@ -217,6 +305,9 @@ function App() {
|
|
|
217
305
|
onNavigate={navigateTo}
|
|
218
306
|
allPages={allPages}
|
|
219
307
|
docContext={docContext}
|
|
308
|
+
versioning={versions || undefined}
|
|
309
|
+
currentVersion={currentVersion}
|
|
310
|
+
basePath={basePath}
|
|
220
311
|
/>
|
|
221
312
|
</>
|
|
222
313
|
);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { pathnameToPageId, pageIdToPath } from "./routing.js";
|
|
3
|
+
import type { MinimalRoute } from "./routing.js";
|
|
4
|
+
|
|
5
|
+
// ── Shared fixtures ──────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const routes: MinimalRoute[] = [
|
|
8
|
+
{ id: "index", urlPath: "/" },
|
|
9
|
+
{ id: "quickstart", urlPath: "/quickstart" },
|
|
10
|
+
{ id: "installation", urlPath: "/installation" },
|
|
11
|
+
{ id: "reference/config", urlPath: "/reference/config" },
|
|
12
|
+
{ id: "guides/migration", urlPath: "/guides/migration" },
|
|
13
|
+
{ id: "v1/index", urlPath: "/v1/" },
|
|
14
|
+
{ id: "v1/quickstart", urlPath: "/v1/quickstart" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// ── pathnameToPageId ─────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
describe("pathnameToPageId", () => {
|
|
20
|
+
const basePath = "/docs";
|
|
21
|
+
|
|
22
|
+
it("resolves root path to index", () => {
|
|
23
|
+
expect(pathnameToPageId("/docs/", basePath, routes)).toBe("index");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("resolves root without trailing slash", () => {
|
|
27
|
+
expect(pathnameToPageId("/docs", basePath, routes)).toBe("index");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("resolves top-level page", () => {
|
|
31
|
+
expect(pathnameToPageId("/docs/quickstart", basePath, routes)).toBe("quickstart");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("resolves nested page", () => {
|
|
35
|
+
expect(pathnameToPageId("/docs/reference/config", basePath, routes)).toBe("reference/config");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("resolves deeply nested page", () => {
|
|
39
|
+
expect(pathnameToPageId("/docs/guides/migration", basePath, routes)).toBe("guides/migration");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns null for unknown page", () => {
|
|
43
|
+
expect(pathnameToPageId("/docs/nonexistent", basePath, routes)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("strips /index.html suffix", () => {
|
|
47
|
+
expect(pathnameToPageId("/docs/quickstart/index.html", basePath, routes)).toBe("quickstart");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("strips .html suffix", () => {
|
|
51
|
+
expect(pathnameToPageId("/docs/quickstart.html", basePath, routes)).toBe("quickstart");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("strips trailing slash", () => {
|
|
55
|
+
expect(pathnameToPageId("/docs/quickstart/", basePath, routes)).toBe("quickstart");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("resolves versioned page", () => {
|
|
59
|
+
expect(pathnameToPageId("/docs/v1/quickstart", basePath, routes)).toBe("v1/quickstart");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("resolves versioned index", () => {
|
|
63
|
+
// /docs/v1/ → strip basePath → /v1/ → strip leading / → v1/ → strip trailing / → v1
|
|
64
|
+
// But our route ID is "v1/index", not "v1", so this needs to resolve properly
|
|
65
|
+
// After all stripping: "v1" — not in routes (v1/index is). Let's test this edge case.
|
|
66
|
+
expect(pathnameToPageId("/docs/v1", basePath, routes)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("with empty basePath", () => {
|
|
70
|
+
it("resolves root to index", () => {
|
|
71
|
+
expect(pathnameToPageId("/", "", routes)).toBe("index");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("resolves page at root", () => {
|
|
75
|
+
expect(pathnameToPageId("/quickstart", "", routes)).toBe("quickstart");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("resolves nested page at root", () => {
|
|
79
|
+
expect(pathnameToPageId("/reference/config", "", routes)).toBe("reference/config");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("with / basePath", () => {
|
|
84
|
+
it("resolves top-level page", () => {
|
|
85
|
+
expect(pathnameToPageId("/quickstart", "", routes)).toBe("quickstart");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── pageIdToPath ─────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe("pageIdToPath", () => {
|
|
93
|
+
const basePath = "/docs";
|
|
94
|
+
|
|
95
|
+
it("builds path for index", () => {
|
|
96
|
+
expect(pageIdToPath("index", basePath, routes)).toBe("/docs/");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("builds path for top-level page", () => {
|
|
100
|
+
expect(pageIdToPath("quickstart", basePath, routes)).toBe("/docs/quickstart");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("builds path for nested page", () => {
|
|
104
|
+
expect(pageIdToPath("reference/config", basePath, routes)).toBe("/docs/reference/config");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("builds path for versioned page", () => {
|
|
108
|
+
expect(pageIdToPath("v1/quickstart", basePath, routes)).toBe("/docs/v1/quickstart");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("falls back to basePath + id for unknown route", () => {
|
|
112
|
+
expect(pageIdToPath("unknown-page", basePath, routes)).toBe("/docs/unknown-page");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("with empty basePath", () => {
|
|
116
|
+
it("builds path for index", () => {
|
|
117
|
+
expect(pageIdToPath("index", "", routes)).toBe("/");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("builds path for page", () => {
|
|
121
|
+
expect(pageIdToPath("quickstart", "", routes)).toBe("/quickstart");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/routing.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure routing utilities for History API navigation.
|
|
3
|
+
* Extracted from entry.tsx so they can be unit-tested independently.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface MinimalRoute {
|
|
7
|
+
id: string;
|
|
8
|
+
urlPath: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract page ID from a pathname by stripping basePath prefix.
|
|
13
|
+
* Returns null if the resolved ID doesn't match any known route.
|
|
14
|
+
*/
|
|
15
|
+
export function pathnameToPageId(
|
|
16
|
+
pathname: string,
|
|
17
|
+
basePath: string,
|
|
18
|
+
routes: MinimalRoute[],
|
|
19
|
+
): string | null {
|
|
20
|
+
let relative = pathname;
|
|
21
|
+
if (basePath && relative.startsWith(basePath)) {
|
|
22
|
+
relative = relative.slice(basePath.length);
|
|
23
|
+
}
|
|
24
|
+
const id =
|
|
25
|
+
relative
|
|
26
|
+
.replace(/^\//, "")
|
|
27
|
+
.replace(/\/index\.html$/, "")
|
|
28
|
+
.replace(/\.html$/, "")
|
|
29
|
+
.replace(/\/$/, "") || "index";
|
|
30
|
+
const route = routes.find((r) => r.id === id);
|
|
31
|
+
return route ? id : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the full URL path for a page ID (basePath + urlPath).
|
|
36
|
+
*/
|
|
37
|
+
export function pageIdToPath(
|
|
38
|
+
id: string,
|
|
39
|
+
basePath: string,
|
|
40
|
+
routes: MinimalRoute[],
|
|
41
|
+
): string {
|
|
42
|
+
const route = routes.find((r) => r.id === id);
|
|
43
|
+
if (route) return basePath + route.urlPath;
|
|
44
|
+
return basePath + "/" + id;
|
|
45
|
+
}
|