@tomehq/theme 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -65
- package/dist/{chunk-QYINBNMJ.js → chunk-FIUZY65C.js} +131 -38
- package/dist/entry.js +1 -1
- package/dist/index.d.ts +78 -0
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/src/Shell.test.tsx +55 -0
- package/src/Shell.tsx +10 -10
- package/src/entry-helpers.test.ts +60 -12
- package/src/entry-helpers.ts +56 -30
- package/src/entry.test.tsx +208 -3
- package/src/entry.tsx +29 -7
- package/src/presets.test.ts +4 -2
- package/src/presets.ts +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,65 +1 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
## 0.3.4
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- f03e6c2: Fix page scrolling beyond viewport bounds in all directions. Lock layout to 100vh with overflow hidden on html, body, and root container.
|
|
8
|
-
|
|
9
|
-
## 0.3.3
|
|
10
|
-
|
|
11
|
-
### Patch Changes
|
|
12
|
-
|
|
13
|
-
- 062349c: Fix MCP server stdout corruption, add dashboard mobile responsiveness, improve test coverage, and update docs.
|
|
14
|
-
|
|
15
|
-
- Fix: MCP CLI banner no longer writes to stdout, preventing JSON-RPC protocol corruption
|
|
16
|
-
- Fix: API Playground prop wiring and githubSource route crash
|
|
17
|
-
- Feat: MCP server `createMcpServer()` exported for programmatic use with graceful shutdown
|
|
18
|
-
- Feat: Dashboard mobile-responsive layout with media query breakpoints
|
|
19
|
-
- Feat: 13 MCP server integration tests using InMemoryTransport
|
|
20
|
-
- Docs: Add missing typedoc CLI command, fix social link examples, update package list
|
|
21
|
-
|
|
22
|
-
- Updated dependencies [062349c]
|
|
23
|
-
- @tomehq/core@0.3.3
|
|
24
|
-
- @tomehq/components@0.3.3
|
|
25
|
-
|
|
26
|
-
## 0.2.8
|
|
27
|
-
|
|
28
|
-
### Minor Changes
|
|
29
|
-
|
|
30
|
-
- Replace hash-based SPA routing with History API (pushState + popstate + pathname parsing)
|
|
31
|
-
- Content link interception: in-content markdown links navigate via SPA instead of full page reload
|
|
32
|
-
- Banner link internal navigation support
|
|
33
|
-
- Algolia search basePath stripping for correct page ID extraction
|
|
34
|
-
- Extract routing helpers (`pathnameToPageId`, `pageIdToPath`) into testable `routing.ts` module
|
|
35
|
-
- Extract entry helpers (`loadPage`, `computeEditUrl`, `resolveInitialPageId`, `detectCurrentVersion`) into testable `entry-helpers.ts` module
|
|
36
|
-
- Pass `basePath` prop through Shell for correct URL construction
|
|
37
|
-
- Updated dependencies
|
|
38
|
-
- @tomehq/core@0.2.8
|
|
39
|
-
- @tomehq/components@0.2.8
|
|
40
|
-
|
|
41
|
-
## 0.2.0
|
|
42
|
-
|
|
43
|
-
### Minor Changes
|
|
44
|
-
|
|
45
|
-
- Shell: logo links back to landing page, dynamic version in sidebar footer
|
|
46
|
-
- Shell: edit link support, table of contents depth config, changelog page layout
|
|
47
|
-
- Entry: pass new config fields (editLink, tableOfContents, plugins) to Shell
|
|
48
|
-
- Updated dependencies
|
|
49
|
-
- @tomehq/core@0.2.0
|
|
50
|
-
- @tomehq/components@0.2.0
|
|
51
|
-
|
|
52
|
-
## 0.1.2
|
|
53
|
-
|
|
54
|
-
### Patch Changes
|
|
55
|
-
|
|
56
|
-
- Updated dependencies
|
|
57
|
-
- @tomehq/components@0.1.1
|
|
58
|
-
|
|
59
|
-
## 0.1.1
|
|
60
|
-
|
|
61
|
-
### Patch Changes
|
|
62
|
-
|
|
63
|
-
- Fix bugs found in functionality audit: invalid search provider, AI key naming mismatch, hardcoded version. Remove dead billing stubs. Add 57 API route tests.
|
|
64
|
-
- Updated dependencies
|
|
65
|
-
- @tomehq/core@0.1.1
|
|
1
|
+
# Changelog
|
|
@@ -3,7 +3,7 @@ import { useState as useState3, useEffect as useEffect3, useCallback as useCallb
|
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
|
|
5
5
|
// src/Shell.tsx
|
|
6
|
-
import React2, { useState as useState2, useEffect as useEffect2, useRef as useRef2, useCallback as useCallback2 } from "react";
|
|
6
|
+
import React2, { useState as useState2, useEffect as useEffect2, useLayoutEffect, useRef as useRef2, useCallback as useCallback2 } from "react";
|
|
7
7
|
|
|
8
8
|
// src/presets.ts
|
|
9
9
|
var THEME_PRESETS = {
|
|
@@ -76,6 +76,76 @@ var THEME_PRESETS = {
|
|
|
76
76
|
hdBg: "rgba(246,244,240,0.92)"
|
|
77
77
|
},
|
|
78
78
|
fonts: { heading: "Cormorant Garamond", body: "Bricolage Grotesque", code: "Fira Code" }
|
|
79
|
+
},
|
|
80
|
+
cipher: {
|
|
81
|
+
dark: {
|
|
82
|
+
bg: "#050508",
|
|
83
|
+
sf: "#0c0c12",
|
|
84
|
+
sfH: "#12121a",
|
|
85
|
+
bd: "#1a1a25",
|
|
86
|
+
tx: "#d4ff00",
|
|
87
|
+
tx2: "#8a90a0",
|
|
88
|
+
txM: "#6a7080",
|
|
89
|
+
ac: "#6666ff",
|
|
90
|
+
acD: "rgba(102,102,255,0.10)",
|
|
91
|
+
acT: "#8080ff",
|
|
92
|
+
cdBg: "#08080e",
|
|
93
|
+
cdTx: "#b0c870",
|
|
94
|
+
sbBg: "#08080d",
|
|
95
|
+
hdBg: "rgba(5,5,8,0.88)"
|
|
96
|
+
},
|
|
97
|
+
light: {
|
|
98
|
+
bg: "#f0f2f5",
|
|
99
|
+
sf: "#ffffff",
|
|
100
|
+
sfH: "#e8eaef",
|
|
101
|
+
bd: "#d0d4db",
|
|
102
|
+
tx: "#0f1219",
|
|
103
|
+
tx2: "#4a5060",
|
|
104
|
+
txM: "#6a7080",
|
|
105
|
+
ac: "#2020cc",
|
|
106
|
+
acD: "rgba(32,32,204,0.08)",
|
|
107
|
+
acT: "#1a1aa8",
|
|
108
|
+
cdBg: "#e6e9ef",
|
|
109
|
+
cdTx: "#2a3520",
|
|
110
|
+
sbBg: "#ebedf2",
|
|
111
|
+
hdBg: "rgba(240,242,245,0.90)"
|
|
112
|
+
},
|
|
113
|
+
fonts: { heading: "Bodoni Moda", body: "Space Grotesk", code: "Source Code Pro" }
|
|
114
|
+
},
|
|
115
|
+
mint: {
|
|
116
|
+
dark: {
|
|
117
|
+
bg: "#0d1117",
|
|
118
|
+
sf: "#161b22",
|
|
119
|
+
sfH: "#1c2129",
|
|
120
|
+
bd: "#21262d",
|
|
121
|
+
tx: "#e6edf3",
|
|
122
|
+
tx2: "#8b949e",
|
|
123
|
+
txM: "#6e7681",
|
|
124
|
+
ac: "#0ea371",
|
|
125
|
+
acD: "rgba(14,163,113,0.10)",
|
|
126
|
+
acT: "#2dd4a0",
|
|
127
|
+
cdBg: "#0a0e14",
|
|
128
|
+
cdTx: "#adbac7",
|
|
129
|
+
sbBg: "#0d1117",
|
|
130
|
+
hdBg: "rgba(13,17,23,0.88)"
|
|
131
|
+
},
|
|
132
|
+
light: {
|
|
133
|
+
bg: "#ffffff",
|
|
134
|
+
sf: "#f6f8fa",
|
|
135
|
+
sfH: "#eef1f5",
|
|
136
|
+
bd: "#d8dee4",
|
|
137
|
+
tx: "#1f2328",
|
|
138
|
+
tx2: "#59636e",
|
|
139
|
+
txM: "#6e7681",
|
|
140
|
+
ac: "#0a7b53",
|
|
141
|
+
acD: "rgba(10,123,83,0.07)",
|
|
142
|
+
acT: "#087a50",
|
|
143
|
+
cdBg: "#f0f3f6",
|
|
144
|
+
cdTx: "#24292f",
|
|
145
|
+
sbBg: "#f6f8fa",
|
|
146
|
+
hdBg: "rgba(255,255,255,0.90)"
|
|
147
|
+
},
|
|
148
|
+
fonts: { heading: "Inter", body: "Inter", code: "Fira Code" }
|
|
79
149
|
}
|
|
80
150
|
};
|
|
81
151
|
|
|
@@ -842,7 +912,7 @@ function Shell({
|
|
|
842
912
|
const [isDark, setDark] = useState2(() => {
|
|
843
913
|
if (themeMode === "dark") return true;
|
|
844
914
|
if (themeMode === "light") return false;
|
|
845
|
-
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ??
|
|
915
|
+
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
|
846
916
|
});
|
|
847
917
|
const [mobile, setMobile] = useState2(() => typeof window !== "undefined" && window.innerWidth < 768);
|
|
848
918
|
const [sbOpen, setSb] = useState2(() => typeof window !== "undefined" && window.innerWidth >= 768);
|
|
@@ -997,14 +1067,12 @@ function Shell({
|
|
|
997
1067
|
useEffect2(() => {
|
|
998
1068
|
setActiveHeadingId("");
|
|
999
1069
|
}, [currentPageId]);
|
|
1000
|
-
|
|
1070
|
+
useLayoutEffect(() => {
|
|
1001
1071
|
if (!htmlContentRef.current || !pageHtml) return;
|
|
1002
1072
|
const stripped = pageHtml.replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, "");
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
}
|
|
1007
|
-
}, [pageHtml]);
|
|
1073
|
+
htmlContentRef.current.innerHTML = stripped;
|
|
1074
|
+
lastHtmlRef.current = stripped;
|
|
1075
|
+
}, [pageHtml, currentPageId]);
|
|
1008
1076
|
const scrollToHeading = useCallback2((e, id) => {
|
|
1009
1077
|
e.preventDefault();
|
|
1010
1078
|
const scrollRoot = contentRef.current;
|
|
@@ -1054,7 +1122,7 @@ function Shell({
|
|
|
1054
1122
|
const PageComponent = pageComponent;
|
|
1055
1123
|
const bannerLink = config2.banner?.link;
|
|
1056
1124
|
const bannerIsInternal = bannerLink ? bannerLink.startsWith("#") || basePath2 && bannerLink.startsWith(basePath2 + "/") : false;
|
|
1057
|
-
return /* @__PURE__ */ jsxs2("div", { dir, className: "tome-grain", style: { ...cssVars, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", height: "100vh", overflow: "
|
|
1125
|
+
return /* @__PURE__ */ jsxs2("div", { dir, className: "tome-grain", style: { ...cssVars, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", height: "100vh", overflow: "clip" }, children: [
|
|
1058
1126
|
config2.banner?.text && !bannerDismissed && /* @__PURE__ */ jsxs2("div", { style: {
|
|
1059
1127
|
display: "flex",
|
|
1060
1128
|
alignItems: "center",
|
|
@@ -1591,7 +1659,7 @@ function Shell({
|
|
|
1591
1659
|
}
|
|
1592
1660
|
),
|
|
1593
1661
|
/* @__PURE__ */ jsxs2("div", { ref: contentRef, style: { flex: 1, overflow: "auto", display: "flex" }, children: [
|
|
1594
|
-
/* @__PURE__ */ jsxs2("main", { style: { flex: 1, maxWidth: mobile ? "100%" : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }, children: [
|
|
1662
|
+
/* @__PURE__ */ jsxs2("main", { style: { flex: 1, maxWidth: mobile ? "100%" : apiManifest ? 1100 : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }, children: [
|
|
1595
1663
|
breadcrumbs.length > 0 && /* @__PURE__ */ jsx2("nav", { "aria-label": "Breadcrumbs", "data-testid": "breadcrumbs", style: {
|
|
1596
1664
|
display: "flex",
|
|
1597
1665
|
alignItems: "center",
|
|
@@ -1625,7 +1693,8 @@ function Shell({
|
|
|
1625
1693
|
{
|
|
1626
1694
|
className: "tome-content",
|
|
1627
1695
|
ref: htmlContentRef
|
|
1628
|
-
}
|
|
1696
|
+
},
|
|
1697
|
+
currentPageId
|
|
1629
1698
|
)
|
|
1630
1699
|
) }),
|
|
1631
1700
|
overrides2?.PageFooter ? /* @__PURE__ */ jsx2(
|
|
@@ -1968,6 +2037,21 @@ function pageIdToPath(id, basePath2, routes2) {
|
|
|
1968
2037
|
}
|
|
1969
2038
|
|
|
1970
2039
|
// src/entry-helpers.ts
|
|
2040
|
+
var PageNotFoundError = class extends Error {
|
|
2041
|
+
code = "PAGE_NOT_FOUND";
|
|
2042
|
+
constructor(pageId) {
|
|
2043
|
+
super(`Page not found: ${pageId}`);
|
|
2044
|
+
this.name = "PageNotFoundError";
|
|
2045
|
+
}
|
|
2046
|
+
};
|
|
2047
|
+
var PageLoadError = class extends Error {
|
|
2048
|
+
code = "PAGE_LOAD_ERROR";
|
|
2049
|
+
constructor(pageId, cause) {
|
|
2050
|
+
super(`Failed to load page: ${pageId}`);
|
|
2051
|
+
this.name = "PageLoadError";
|
|
2052
|
+
if (cause) this.cause = cause;
|
|
2053
|
+
}
|
|
2054
|
+
};
|
|
1971
2055
|
function computeEditUrl(editLink, filePath) {
|
|
1972
2056
|
if (!editLink || !filePath) return void 0;
|
|
1973
2057
|
const { repo, branch = "main", dir = "" } = editLink;
|
|
@@ -1982,29 +2066,29 @@ function resolveInitialPageId(pathname, hash, routes2, basePath2, pathnameToPage
|
|
|
1982
2066
|
return routes2[0]?.id || "index";
|
|
1983
2067
|
}
|
|
1984
2068
|
async function loadPage(id, routes2, loadPageModule2) {
|
|
2069
|
+
const route = routes2.find((r) => r.id === id);
|
|
2070
|
+
let mod;
|
|
1985
2071
|
try {
|
|
1986
|
-
|
|
1987
|
-
const mod = await loadPageModule2(id);
|
|
1988
|
-
if (route?.isMdx && mod.meta) {
|
|
1989
|
-
return {
|
|
1990
|
-
isMdx: true,
|
|
1991
|
-
component: mod.default,
|
|
1992
|
-
frontmatter: mod.meta.frontmatter,
|
|
1993
|
-
headings: mod.meta.headings
|
|
1994
|
-
};
|
|
1995
|
-
}
|
|
1996
|
-
if (!mod.default) return null;
|
|
1997
|
-
if (mod.isApiReference && mod.apiManifest) {
|
|
1998
|
-
return { isMdx: false, isApiReference: true, ...mod.default, apiManifest: mod.apiManifest };
|
|
1999
|
-
}
|
|
2000
|
-
if (mod.isChangelog && mod.changelogEntries) {
|
|
2001
|
-
return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
|
|
2002
|
-
}
|
|
2003
|
-
return { isMdx: false, ...mod.default };
|
|
2072
|
+
mod = await loadPageModule2(id);
|
|
2004
2073
|
} catch (err) {
|
|
2005
|
-
|
|
2006
|
-
return null;
|
|
2074
|
+
throw new PageLoadError(id, err);
|
|
2007
2075
|
}
|
|
2076
|
+
if (route?.isMdx && mod.meta) {
|
|
2077
|
+
return {
|
|
2078
|
+
isMdx: true,
|
|
2079
|
+
component: mod.default,
|
|
2080
|
+
frontmatter: mod.meta.frontmatter,
|
|
2081
|
+
headings: mod.meta.headings
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
if (!mod.default) throw new PageNotFoundError(id);
|
|
2085
|
+
if (mod.isApiReference && mod.apiManifest) {
|
|
2086
|
+
return { isMdx: false, isApiReference: true, ...mod.default, apiManifest: mod.apiManifest };
|
|
2087
|
+
}
|
|
2088
|
+
if (mod.isChangelog && mod.changelogEntries) {
|
|
2089
|
+
return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
|
|
2090
|
+
}
|
|
2091
|
+
return { isMdx: false, ...mod.default };
|
|
2008
2092
|
}
|
|
2009
2093
|
function detectCurrentVersion(currentRoute, versions2) {
|
|
2010
2094
|
return currentRoute?.version || (versions2?.current ?? void 0);
|
|
@@ -2050,14 +2134,13 @@ var MDX_COMPONENTS = {
|
|
|
2050
2134
|
CardGrid
|
|
2051
2135
|
};
|
|
2052
2136
|
var contentStyles = `
|
|
2053
|
-
@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,700&family=Fira+Code:wght@400;500;600&display=swap');
|
|
2137
|
+
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bricolage+Grotesque:wght@300;400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,700&family=Fira+Code:wght@400;500;600&family=Bodoni+Moda:ital,wght@0,400;0,700;0,900;1,400&family=Space+Grotesk:wght@400;500;600;700&family=Source+Code+Pro:wght@400;500;600&family=Inter:wght@300;400;500;600;700&display=swap');
|
|
2054
2138
|
|
|
2055
|
-
html, body { margin: 0; padding: 0; height: 100%; overflow:
|
|
2056
|
-
#tome-root { height: 100%; overflow:
|
|
2139
|
+
html, body { margin: 0; padding: 0; height: 100%; overflow: clip; }
|
|
2140
|
+
#tome-root { height: 100%; overflow: clip; }
|
|
2057
2141
|
|
|
2058
2142
|
.tome-content h1 { display: none; }
|
|
2059
|
-
.tome-content h2 { font-family: var(--font-body); font-size: 1.35em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em;
|
|
2060
|
-
.tome-content h2::before { content: "#"; font-family: var(--font-heading); font-size: 1.2em; font-weight: 300; font-style: italic; color: var(--ac); opacity: 0.5; }
|
|
2143
|
+
.tome-content h2 { font-family: var(--font-body); font-size: 1.35em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; letter-spacing: 0.01em; }
|
|
2061
2144
|
.tome-content h3 { font-family: var(--font-body); font-size: 1.15em; font-weight: 600; margin-top: 1.5em; margin-bottom: 0.5em; }
|
|
2062
2145
|
.tome-content h4 { font-family: var(--font-body); font-size: 1.05em; font-weight: 600; margin-top: 1.2em; margin-bottom: 0.5em; }
|
|
2063
2146
|
.tome-content p { color: var(--tx2); line-height: 1.8; margin-bottom: 1em; font-size: 14.5px; }
|
|
@@ -2286,16 +2369,26 @@ function App() {
|
|
|
2286
2369
|
const [currentPageId, setCurrentPageId] = useState3(_initialPageId);
|
|
2287
2370
|
const [pageData, setPageData] = useState3(null);
|
|
2288
2371
|
const [loading, setLoading] = useState3(true);
|
|
2372
|
+
const navCounterRef = useRef3(0);
|
|
2289
2373
|
const navigateTo = useCallback3(async (id, opts) => {
|
|
2374
|
+
const navId = ++navCounterRef.current;
|
|
2290
2375
|
setLoading(true);
|
|
2291
|
-
|
|
2376
|
+
let data;
|
|
2377
|
+
try {
|
|
2378
|
+
data = await loadPage(id, routes, loadPageModule);
|
|
2379
|
+
} catch (err) {
|
|
2380
|
+
if (navCounterRef.current !== navId) return;
|
|
2381
|
+
console.error(`[tome] Navigation failed for page: ${id}`, err);
|
|
2382
|
+
data = null;
|
|
2383
|
+
}
|
|
2384
|
+
if (navCounterRef.current !== navId) return;
|
|
2292
2385
|
const fullPath = pageIdToPath2(id);
|
|
2293
2386
|
if (opts?.replace) {
|
|
2294
2387
|
window.history.replaceState(null, "", fullPath);
|
|
2295
2388
|
} else {
|
|
2296
2389
|
window.history.pushState(null, "", fullPath);
|
|
2297
2390
|
}
|
|
2298
|
-
|
|
2391
|
+
setCurrentPageId(id);
|
|
2299
2392
|
setPageData(data);
|
|
2300
2393
|
setLoading(false);
|
|
2301
2394
|
if (!opts?.skipScroll) {
|
package/dist/entry.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -252,6 +252,84 @@ declare const THEME_PRESETS: {
|
|
|
252
252
|
readonly code: "Fira Code";
|
|
253
253
|
};
|
|
254
254
|
};
|
|
255
|
+
readonly cipher: {
|
|
256
|
+
readonly dark: {
|
|
257
|
+
readonly bg: "#050508";
|
|
258
|
+
readonly sf: "#0c0c12";
|
|
259
|
+
readonly sfH: "#12121a";
|
|
260
|
+
readonly bd: "#1a1a25";
|
|
261
|
+
readonly tx: "#d4ff00";
|
|
262
|
+
readonly tx2: "#8a90a0";
|
|
263
|
+
readonly txM: "#6a7080";
|
|
264
|
+
readonly ac: "#6666ff";
|
|
265
|
+
readonly acD: "rgba(102,102,255,0.10)";
|
|
266
|
+
readonly acT: "#8080ff";
|
|
267
|
+
readonly cdBg: "#08080e";
|
|
268
|
+
readonly cdTx: "#b0c870";
|
|
269
|
+
readonly sbBg: "#08080d";
|
|
270
|
+
readonly hdBg: "rgba(5,5,8,0.88)";
|
|
271
|
+
};
|
|
272
|
+
readonly light: {
|
|
273
|
+
readonly bg: "#f0f2f5";
|
|
274
|
+
readonly sf: "#ffffff";
|
|
275
|
+
readonly sfH: "#e8eaef";
|
|
276
|
+
readonly bd: "#d0d4db";
|
|
277
|
+
readonly tx: "#0f1219";
|
|
278
|
+
readonly tx2: "#4a5060";
|
|
279
|
+
readonly txM: "#6a7080";
|
|
280
|
+
readonly ac: "#2020cc";
|
|
281
|
+
readonly acD: "rgba(32,32,204,0.08)";
|
|
282
|
+
readonly acT: "#1a1aa8";
|
|
283
|
+
readonly cdBg: "#e6e9ef";
|
|
284
|
+
readonly cdTx: "#2a3520";
|
|
285
|
+
readonly sbBg: "#ebedf2";
|
|
286
|
+
readonly hdBg: "rgba(240,242,245,0.90)";
|
|
287
|
+
};
|
|
288
|
+
readonly fonts: {
|
|
289
|
+
readonly heading: "Bodoni Moda";
|
|
290
|
+
readonly body: "Space Grotesk";
|
|
291
|
+
readonly code: "Source Code Pro";
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
readonly mint: {
|
|
295
|
+
readonly dark: {
|
|
296
|
+
readonly bg: "#0d1117";
|
|
297
|
+
readonly sf: "#161b22";
|
|
298
|
+
readonly sfH: "#1c2129";
|
|
299
|
+
readonly bd: "#21262d";
|
|
300
|
+
readonly tx: "#e6edf3";
|
|
301
|
+
readonly tx2: "#8b949e";
|
|
302
|
+
readonly txM: "#6e7681";
|
|
303
|
+
readonly ac: "#0ea371";
|
|
304
|
+
readonly acD: "rgba(14,163,113,0.10)";
|
|
305
|
+
readonly acT: "#2dd4a0";
|
|
306
|
+
readonly cdBg: "#0a0e14";
|
|
307
|
+
readonly cdTx: "#adbac7";
|
|
308
|
+
readonly sbBg: "#0d1117";
|
|
309
|
+
readonly hdBg: "rgba(13,17,23,0.88)";
|
|
310
|
+
};
|
|
311
|
+
readonly light: {
|
|
312
|
+
readonly bg: "#ffffff";
|
|
313
|
+
readonly sf: "#f6f8fa";
|
|
314
|
+
readonly sfH: "#eef1f5";
|
|
315
|
+
readonly bd: "#d8dee4";
|
|
316
|
+
readonly tx: "#1f2328";
|
|
317
|
+
readonly tx2: "#59636e";
|
|
318
|
+
readonly txM: "#6e7681";
|
|
319
|
+
readonly ac: "#0a7b53";
|
|
320
|
+
readonly acD: "rgba(10,123,83,0.07)";
|
|
321
|
+
readonly acT: "#087a50";
|
|
322
|
+
readonly cdBg: "#f0f3f6";
|
|
323
|
+
readonly cdTx: "#24292f";
|
|
324
|
+
readonly sbBg: "#f6f8fa";
|
|
325
|
+
readonly hdBg: "rgba(255,255,255,0.90)";
|
|
326
|
+
};
|
|
327
|
+
readonly fonts: {
|
|
328
|
+
readonly heading: "Inter";
|
|
329
|
+
readonly body: "Inter";
|
|
330
|
+
readonly code: "Fira Code";
|
|
331
|
+
};
|
|
332
|
+
};
|
|
255
333
|
};
|
|
256
334
|
type PresetName = keyof typeof THEME_PRESETS;
|
|
257
335
|
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomehq/theme",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Tome default theme and React app shell",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.tsx",
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"./entry": "./src/entry.tsx"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@tomehq/components": "0.
|
|
13
|
-
"@tomehq/core": "0.
|
|
12
|
+
"@tomehq/components": "0.5.0",
|
|
13
|
+
"@tomehq/core": "0.5.0"
|
|
14
14
|
},
|
|
15
15
|
"peerDependencies": {
|
|
16
16
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/Shell.test.tsx
CHANGED
|
@@ -180,6 +180,18 @@ describe("Shell theme mode", () => {
|
|
|
180
180
|
const buttons = footer?.querySelectorAll("button");
|
|
181
181
|
expect(buttons?.length).toBeGreaterThan(0);
|
|
182
182
|
});
|
|
183
|
+
|
|
184
|
+
it("defaults to light mode when mode is 'auto' and system preference is unavailable", () => {
|
|
185
|
+
const { container } = renderShell({
|
|
186
|
+
config: { ...baseConfig, theme: { preset: "amber", mode: "auto" } },
|
|
187
|
+
});
|
|
188
|
+
// matchMedia mock returns matches: false → light mode
|
|
189
|
+
// In light mode, the root container uses light theme background
|
|
190
|
+
const root = container.firstElementChild as HTMLElement;
|
|
191
|
+
const bg = root?.style.getPropertyValue("--bg");
|
|
192
|
+
// Amber light bg is #fafaf9 (not dark bg #09090b)
|
|
193
|
+
expect(bg).toBe("#fafaf9");
|
|
194
|
+
});
|
|
183
195
|
});
|
|
184
196
|
|
|
185
197
|
// ── TOC (TOM-52) ──────────────────────────────────────────
|
|
@@ -858,6 +870,31 @@ describe("Shell feedback widget", () => {
|
|
|
858
870
|
fireEvent.click(screen.getByText("\uD83D\uDC4E"));
|
|
859
871
|
expect(screen.getByText("Thanks for your feedback!")).toBeInTheDocument();
|
|
860
872
|
});
|
|
873
|
+
|
|
874
|
+
it("feedback section is inside the scrollable content container, not clipped by root", () => {
|
|
875
|
+
renderShell();
|
|
876
|
+
const feedbackEl = screen.getByText("Was this helpful?");
|
|
877
|
+
// The feedback widget must be a descendant of the overflow:auto scroll container
|
|
878
|
+
const scrollContainer = feedbackEl.closest('[style*="overflow: auto"]') || feedbackEl.closest('[style*="overflow:auto"]');
|
|
879
|
+
expect(scrollContainer).not.toBeNull();
|
|
880
|
+
// The root container must use overflow:clip (not hidden) to avoid clip containment issues
|
|
881
|
+
const root = feedbackEl.closest(".tome-grain");
|
|
882
|
+
expect(root).not.toBeNull();
|
|
883
|
+
expect((root as HTMLElement).style.overflow).toBe("clip");
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it("feedback section remains in DOM and is not conditionally removed", () => {
|
|
887
|
+
const { container } = renderShell();
|
|
888
|
+
// Feedback must always render inside <main>
|
|
889
|
+
const main = container.querySelector("main");
|
|
890
|
+
expect(main).not.toBeNull();
|
|
891
|
+
const feedbackText = within(main!).getByText("Was this helpful?");
|
|
892
|
+
expect(feedbackText).toBeInTheDocument();
|
|
893
|
+
// Verify feedback is not position:fixed or absolute (it flows with content)
|
|
894
|
+
const feedbackParent = feedbackText.closest("div")!;
|
|
895
|
+
expect(feedbackParent.style.position).not.toBe("fixed");
|
|
896
|
+
expect(feedbackParent.style.position).not.toBe("absolute");
|
|
897
|
+
});
|
|
861
898
|
});
|
|
862
899
|
|
|
863
900
|
// ── Breadcrumbs ──────────────────────────────────────────
|
|
@@ -1186,6 +1223,24 @@ describe("Shell API reference rendering", () => {
|
|
|
1186
1223
|
expect(screen.queryByTestId("api-playground")).not.toBeInTheDocument();
|
|
1187
1224
|
expect(screen.queryByTestId("api-auth")).not.toBeInTheDocument();
|
|
1188
1225
|
});
|
|
1226
|
+
|
|
1227
|
+
it("uses wider max-width for API reference pages", () => {
|
|
1228
|
+
const { container } = renderShell({
|
|
1229
|
+
apiManifest: mockManifest,
|
|
1230
|
+
ApiReferenceComponent: MockApiRef,
|
|
1231
|
+
pageHtml: undefined,
|
|
1232
|
+
});
|
|
1233
|
+
const main = container.querySelector("main") as HTMLElement;
|
|
1234
|
+
expect(main.style.maxWidth).toBe("1100px");
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it("uses standard max-width for non-API pages", () => {
|
|
1238
|
+
const { container } = renderShell({
|
|
1239
|
+
pageHtml: "<p>Regular prose content</p>",
|
|
1240
|
+
});
|
|
1241
|
+
const main = container.querySelector("main") as HTMLElement;
|
|
1242
|
+
expect(main.style.maxWidth).toBe("760px");
|
|
1243
|
+
});
|
|
1189
1244
|
});
|
|
1190
1245
|
|
|
1191
1246
|
// ── Content link interception ───────────────────────────────
|
package/src/Shell.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
1
|
+
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from "react";
|
|
2
2
|
import { THEME_PRESETS, type PresetName } from "./presets.js";
|
|
3
3
|
import { AiChat } from "./AiChat.js";
|
|
4
4
|
|
|
@@ -413,7 +413,7 @@ export function Shell({
|
|
|
413
413
|
const [isDark, setDark] = useState(() => {
|
|
414
414
|
if (themeMode === "dark") return true;
|
|
415
415
|
if (themeMode === "light") return false;
|
|
416
|
-
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ??
|
|
416
|
+
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
|
417
417
|
});
|
|
418
418
|
|
|
419
419
|
const [mobile, setMobile] = useState(() => typeof window !== "undefined" && window.innerWidth < 768);
|
|
@@ -611,14 +611,13 @@ export function Shell({
|
|
|
611
611
|
|
|
612
612
|
// Set HTML content via ref so React doesn't re-set innerHTML on re-renders
|
|
613
613
|
// (scroll-spy state changes would otherwise destroy client-side mermaid SVGs)
|
|
614
|
-
|
|
614
|
+
// useLayoutEffect ensures innerHTML is set synchronously before paint — no flash
|
|
615
|
+
useLayoutEffect(() => {
|
|
615
616
|
if (!htmlContentRef.current || !pageHtml) return;
|
|
616
617
|
const stripped = pageHtml.replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, "");
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
}
|
|
621
|
-
}, [pageHtml]);
|
|
618
|
+
htmlContentRef.current.innerHTML = stripped;
|
|
619
|
+
lastHtmlRef.current = stripped;
|
|
620
|
+
}, [pageHtml, currentPageId]);
|
|
622
621
|
|
|
623
622
|
// Smooth scroll handler for TOC links
|
|
624
623
|
const scrollToHeading = useCallback((e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
|
@@ -669,7 +668,7 @@ export function Shell({
|
|
|
669
668
|
const bannerIsInternal = bannerLink ? (bannerLink.startsWith("#") || (basePath && bannerLink.startsWith(basePath + "/"))) : false;
|
|
670
669
|
|
|
671
670
|
return (
|
|
672
|
-
<div dir={dir} className="tome-grain" style={{ ...cssVars as React.CSSProperties, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", height: "100vh", overflow: "
|
|
671
|
+
<div dir={dir} className="tome-grain" style={{ ...cssVars as React.CSSProperties, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", height: "100vh", overflow: "clip" }}>
|
|
673
672
|
{/* Banner */}
|
|
674
673
|
{config.banner?.text && !bannerDismissed && (
|
|
675
674
|
<div style={{
|
|
@@ -1128,7 +1127,7 @@ export function Shell({
|
|
|
1128
1127
|
|
|
1129
1128
|
{/* Content + TOC */}
|
|
1130
1129
|
<div ref={contentRef} style={{ flex: 1, overflow: "auto", display: "flex" }}>
|
|
1131
|
-
<main style={{ flex: 1, maxWidth: mobile ? "100%" : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }}>
|
|
1130
|
+
<main style={{ flex: 1, maxWidth: mobile ? "100%" : apiManifest ? 1100 : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }}>
|
|
1132
1131
|
{breadcrumbs.length > 0 && (
|
|
1133
1132
|
<nav aria-label="Breadcrumbs" data-testid="breadcrumbs" style={{
|
|
1134
1133
|
display: "flex", alignItems: "center", gap: 6,
|
|
@@ -1179,6 +1178,7 @@ export function Shell({
|
|
|
1179
1178
|
</div>
|
|
1180
1179
|
) : (
|
|
1181
1180
|
<div
|
|
1181
|
+
key={currentPageId}
|
|
1182
1182
|
className="tome-content"
|
|
1183
1183
|
ref={htmlContentRef}
|
|
1184
1184
|
/>
|
|
@@ -4,6 +4,9 @@ import {
|
|
|
4
4
|
resolveInitialPageId,
|
|
5
5
|
loadPage,
|
|
6
6
|
detectCurrentVersion,
|
|
7
|
+
PageNotFoundError,
|
|
8
|
+
PageLoadError,
|
|
9
|
+
NavigationCancelledError,
|
|
7
10
|
} from "./entry-helpers.js";
|
|
8
11
|
import type { MinimalRoute } from "./routing.js";
|
|
9
12
|
|
|
@@ -196,22 +199,16 @@ describe("loadPage", () => {
|
|
|
196
199
|
}
|
|
197
200
|
});
|
|
198
201
|
|
|
199
|
-
it("
|
|
202
|
+
it("throws PageNotFoundError when module has no default export", async () => {
|
|
200
203
|
const mockLoader = vi.fn().mockResolvedValue({ default: null });
|
|
201
|
-
|
|
202
|
-
expect(
|
|
204
|
+
await expect(loadPage("quickstart", routesWithMeta, mockLoader)).rejects.toThrow(PageNotFoundError);
|
|
205
|
+
await expect(loadPage("quickstart", routesWithMeta, mockLoader)).rejects.toMatchObject({ code: "PAGE_NOT_FOUND" });
|
|
203
206
|
});
|
|
204
207
|
|
|
205
|
-
it("
|
|
208
|
+
it("throws PageLoadError on loader error", async () => {
|
|
206
209
|
const mockLoader = vi.fn().mockRejectedValue(new Error("Module not found"));
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
expect(page).toBeNull();
|
|
210
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
211
|
-
expect.stringContaining("Failed to load page"),
|
|
212
|
-
expect.any(Error),
|
|
213
|
-
);
|
|
214
|
-
consoleSpy.mockRestore();
|
|
210
|
+
await expect(loadPage("quickstart", routesWithMeta, mockLoader)).rejects.toThrow(PageLoadError);
|
|
211
|
+
await expect(loadPage("quickstart", routesWithMeta, mockLoader)).rejects.toMatchObject({ code: "PAGE_LOAD_ERROR" });
|
|
215
212
|
});
|
|
216
213
|
|
|
217
214
|
it("loads a changelog page with entries", async () => {
|
|
@@ -390,3 +387,54 @@ describe("detectCurrentVersion", () => {
|
|
|
390
387
|
expect(detectCurrentVersion({ version: undefined }, { current: undefined })).toBeUndefined();
|
|
391
388
|
});
|
|
392
389
|
});
|
|
390
|
+
|
|
391
|
+
// ── Error type distinguishability ─────────────────────────
|
|
392
|
+
|
|
393
|
+
describe("loadPage — distinguishable error types", () => {
|
|
394
|
+
const routesWithMeta = [
|
|
395
|
+
{ id: "index", urlPath: "/", isMdx: false },
|
|
396
|
+
];
|
|
397
|
+
|
|
398
|
+
it("PageNotFoundError has code PAGE_NOT_FOUND", async () => {
|
|
399
|
+
const mockLoader = vi.fn().mockResolvedValue({ default: null });
|
|
400
|
+
try {
|
|
401
|
+
await loadPage("index", routesWithMeta, mockLoader);
|
|
402
|
+
expect.fail("should have thrown");
|
|
403
|
+
} catch (err: any) {
|
|
404
|
+
expect(err).toBeInstanceOf(PageNotFoundError);
|
|
405
|
+
expect(err.code).toBe("PAGE_NOT_FOUND");
|
|
406
|
+
expect(err.message).toContain("index");
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("PageLoadError has code PAGE_LOAD_ERROR and preserves cause", async () => {
|
|
411
|
+
const cause = new Error("network failure");
|
|
412
|
+
const mockLoader = vi.fn().mockRejectedValue(cause);
|
|
413
|
+
try {
|
|
414
|
+
await loadPage("index", routesWithMeta, mockLoader);
|
|
415
|
+
expect.fail("should have thrown");
|
|
416
|
+
} catch (err: any) {
|
|
417
|
+
expect(err).toBeInstanceOf(PageLoadError);
|
|
418
|
+
expect(err.code).toBe("PAGE_LOAD_ERROR");
|
|
419
|
+
expect(err.cause).toBe(cause);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("NavigationCancelledError has code NAVIGATION_CANCELLED", () => {
|
|
424
|
+
const err = new NavigationCancelledError();
|
|
425
|
+
expect(err).toBeInstanceOf(Error);
|
|
426
|
+
expect(err.code).toBe("NAVIGATION_CANCELLED");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("all three error types are distinguishable from each other", () => {
|
|
430
|
+
const e1 = new PageNotFoundError("x");
|
|
431
|
+
const e2 = new PageLoadError("x");
|
|
432
|
+
const e3 = new NavigationCancelledError();
|
|
433
|
+
expect(e1.code).not.toBe(e2.code);
|
|
434
|
+
expect(e2.code).not.toBe(e3.code);
|
|
435
|
+
expect(e1.code).not.toBe(e3.code);
|
|
436
|
+
expect(e1).toBeInstanceOf(Error);
|
|
437
|
+
expect(e2).toBeInstanceOf(Error);
|
|
438
|
+
expect(e3).toBeInstanceOf(Error);
|
|
439
|
+
});
|
|
440
|
+
});
|
package/src/entry-helpers.ts
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
import type { MinimalRoute } from "./routing.js";
|
|
2
2
|
|
|
3
|
+
// ── NAVIGATION ERRORS ─────────────────────────────────────
|
|
4
|
+
export class PageNotFoundError extends Error {
|
|
5
|
+
readonly code = "PAGE_NOT_FOUND" as const;
|
|
6
|
+
constructor(pageId: string) {
|
|
7
|
+
super(`Page not found: ${pageId}`);
|
|
8
|
+
this.name = "PageNotFoundError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class PageLoadError extends Error {
|
|
13
|
+
readonly code = "PAGE_LOAD_ERROR" as const;
|
|
14
|
+
constructor(pageId: string, cause?: unknown) {
|
|
15
|
+
super(`Failed to load page: ${pageId}`);
|
|
16
|
+
this.name = "PageLoadError";
|
|
17
|
+
if (cause) this.cause = cause;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class NavigationCancelledError extends Error {
|
|
22
|
+
readonly code = "NAVIGATION_CANCELLED" as const;
|
|
23
|
+
constructor() {
|
|
24
|
+
super("Navigation was cancelled by a newer navigation");
|
|
25
|
+
this.name = "NavigationCancelledError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
3
29
|
// ── PAGE TYPES ────────────────────────────────────────────
|
|
4
30
|
export interface HtmlPage {
|
|
5
31
|
isMdx: false;
|
|
@@ -76,39 +102,39 @@ export async function loadPage(
|
|
|
76
102
|
id: string,
|
|
77
103
|
routes: RouteWithMeta[],
|
|
78
104
|
loadPageModule: (id: string) => Promise<any>,
|
|
79
|
-
): Promise<LoadedPage
|
|
105
|
+
): Promise<LoadedPage> {
|
|
106
|
+
const route = routes.find((r) => r.id === id);
|
|
107
|
+
let mod: any;
|
|
80
108
|
try {
|
|
81
|
-
|
|
82
|
-
const mod = await loadPageModule(id);
|
|
83
|
-
|
|
84
|
-
if (route?.isMdx && mod.meta) {
|
|
85
|
-
// MDX page — mod.default is the React component
|
|
86
|
-
return {
|
|
87
|
-
isMdx: true,
|
|
88
|
-
component: mod.default,
|
|
89
|
-
frontmatter: mod.meta.frontmatter,
|
|
90
|
-
headings: mod.meta.headings,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Regular .md page — mod.default is { html, frontmatter, headings }
|
|
95
|
-
if (!mod.default) return null;
|
|
96
|
-
|
|
97
|
-
// API reference page (synthetic route from OpenAPI spec)
|
|
98
|
-
if (mod.isApiReference && mod.apiManifest) {
|
|
99
|
-
return { isMdx: false, isApiReference: true, ...mod.default, apiManifest: mod.apiManifest };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Changelog page type
|
|
103
|
-
if (mod.isChangelog && mod.changelogEntries) {
|
|
104
|
-
return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return { isMdx: false, ...mod.default };
|
|
109
|
+
mod = await loadPageModule(id);
|
|
108
110
|
} catch (err) {
|
|
109
|
-
|
|
110
|
-
return null;
|
|
111
|
+
throw new PageLoadError(id, err);
|
|
111
112
|
}
|
|
113
|
+
|
|
114
|
+
if (route?.isMdx && mod.meta) {
|
|
115
|
+
// MDX page — mod.default is the React component
|
|
116
|
+
return {
|
|
117
|
+
isMdx: true,
|
|
118
|
+
component: mod.default,
|
|
119
|
+
frontmatter: mod.meta.frontmatter,
|
|
120
|
+
headings: mod.meta.headings,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Regular .md page — mod.default is { html, frontmatter, headings }
|
|
125
|
+
if (!mod.default) throw new PageNotFoundError(id);
|
|
126
|
+
|
|
127
|
+
// API reference page (synthetic route from OpenAPI spec)
|
|
128
|
+
if (mod.isApiReference && mod.apiManifest) {
|
|
129
|
+
return { isMdx: false, isApiReference: true, ...mod.default, apiManifest: mod.apiManifest };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Changelog page type
|
|
133
|
+
if (mod.isChangelog && mod.changelogEntries) {
|
|
134
|
+
return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { isMdx: false, ...mod.default };
|
|
112
138
|
}
|
|
113
139
|
|
|
114
140
|
// ── VERSION DETECTION ─────────────────────────────────────
|
package/src/entry.test.tsx
CHANGED
|
@@ -115,11 +115,39 @@ const mockComputeEditUrl = vi.fn().mockReturnValue("https://github.com/test/repo
|
|
|
115
115
|
const mockResolveInitialPageId = vi.fn().mockReturnValue("index");
|
|
116
116
|
const mockDetectCurrentVersion = vi.fn().mockReturnValue(undefined);
|
|
117
117
|
|
|
118
|
+
class NavigationCancelledError extends Error {
|
|
119
|
+
readonly code = "NAVIGATION_CANCELLED" as const;
|
|
120
|
+
constructor() {
|
|
121
|
+
super("Navigation was cancelled by a newer navigation");
|
|
122
|
+
this.name = "NavigationCancelledError";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
class PageNotFoundError extends Error {
|
|
127
|
+
readonly code = "PAGE_NOT_FOUND" as const;
|
|
128
|
+
constructor(pageId: string) {
|
|
129
|
+
super(`Page not found: ${pageId}`);
|
|
130
|
+
this.name = "PageNotFoundError";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
class PageLoadError extends Error {
|
|
135
|
+
readonly code = "PAGE_LOAD_ERROR" as const;
|
|
136
|
+
constructor(pageId: string, cause?: unknown) {
|
|
137
|
+
super(`Failed to load page: ${pageId}`);
|
|
138
|
+
this.name = "PageLoadError";
|
|
139
|
+
if (cause) this.cause = cause;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
118
143
|
vi.mock("./entry-helpers.js", () => ({
|
|
119
144
|
loadPage: (...args: any[]) => mockLoadPage(...args),
|
|
120
145
|
computeEditUrl: (...args: any[]) => mockComputeEditUrl(...args),
|
|
121
146
|
resolveInitialPageId: (...args: any[]) => mockResolveInitialPageId(...args),
|
|
122
147
|
detectCurrentVersion: (...args: any[]) => mockDetectCurrentVersion(...args),
|
|
148
|
+
NavigationCancelledError,
|
|
149
|
+
PageNotFoundError,
|
|
150
|
+
PageLoadError,
|
|
123
151
|
}));
|
|
124
152
|
|
|
125
153
|
vi.mock("./routing.js", () => ({
|
|
@@ -512,11 +540,12 @@ describe("entry.tsx — navigation", () => {
|
|
|
512
540
|
expect(calls.length).toBeGreaterThanOrEqual(2);
|
|
513
541
|
});
|
|
514
542
|
|
|
515
|
-
it("shows 'Not Found' title when page
|
|
543
|
+
it("shows 'Not Found' title when page load throws and not loading", async () => {
|
|
516
544
|
await renderApp();
|
|
517
545
|
|
|
518
|
-
|
|
519
|
-
|
|
546
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
547
|
+
// Navigate to trigger an error — navigateTo catches it and sets data to null
|
|
548
|
+
mockLoadPage.mockRejectedValueOnce(new PageNotFoundError("nonexistent"));
|
|
520
549
|
await act(async () => {
|
|
521
550
|
await capturedShellProps.onNavigate("nonexistent");
|
|
522
551
|
});
|
|
@@ -526,6 +555,7 @@ describe("entry.tsx — navigation", () => {
|
|
|
526
555
|
|
|
527
556
|
expect(capturedShellProps.pageTitle).toBe("Not Found");
|
|
528
557
|
expect(capturedShellProps.pageHtml).toBe("<p>Page not found</p>");
|
|
558
|
+
consoleSpy.mockRestore();
|
|
529
559
|
});
|
|
530
560
|
});
|
|
531
561
|
|
|
@@ -693,3 +723,178 @@ describe("entry.tsx — KaTeX rendering", () => {
|
|
|
693
723
|
katexLink?.remove();
|
|
694
724
|
});
|
|
695
725
|
});
|
|
726
|
+
|
|
727
|
+
// ── History push timing (load before push) ──────────────
|
|
728
|
+
|
|
729
|
+
describe("entry.tsx — history push after page load", () => {
|
|
730
|
+
it("pushes history only after loadPage resolves successfully", async () => {
|
|
731
|
+
await renderApp();
|
|
732
|
+
|
|
733
|
+
const pushCalls: number[] = [];
|
|
734
|
+
const loadCalls: number[] = [];
|
|
735
|
+
let callOrder = 0;
|
|
736
|
+
|
|
737
|
+
(window.history.pushState as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
738
|
+
pushCalls.push(++callOrder);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
mockLoadPage.mockImplementation(async () => {
|
|
742
|
+
loadCalls.push(++callOrder);
|
|
743
|
+
return {
|
|
744
|
+
isMdx: false,
|
|
745
|
+
html: "<p>Page</p>",
|
|
746
|
+
frontmatter: { title: "Page", description: "" },
|
|
747
|
+
headings: [],
|
|
748
|
+
};
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
await act(async () => {
|
|
752
|
+
await capturedShellProps.onNavigate("quickstart");
|
|
753
|
+
});
|
|
754
|
+
await act(async () => {
|
|
755
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// loadPage should have been called BEFORE pushState
|
|
759
|
+
expect(loadCalls.length).toBe(1);
|
|
760
|
+
expect(pushCalls.length).toBe(1);
|
|
761
|
+
expect(loadCalls[0]).toBeLessThan(pushCalls[0]);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("does not push history when loadPage throws", async () => {
|
|
765
|
+
await renderApp();
|
|
766
|
+
|
|
767
|
+
// Reset pushState call count after initial render
|
|
768
|
+
(window.history.pushState as ReturnType<typeof vi.fn>).mockClear();
|
|
769
|
+
|
|
770
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
771
|
+
mockLoadPage.mockRejectedValueOnce(new PageLoadError("broken"));
|
|
772
|
+
|
|
773
|
+
await act(async () => {
|
|
774
|
+
await capturedShellProps.onNavigate("broken");
|
|
775
|
+
});
|
|
776
|
+
await act(async () => {
|
|
777
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// pushState should NOT have been called since the load failed
|
|
781
|
+
// (but it still pushes for the error case since we show Not Found)
|
|
782
|
+
// Actually the current implementation does push even on error to show Not Found
|
|
783
|
+
// The key fix is that it doesn't push BEFORE loading
|
|
784
|
+
consoleSpy.mockRestore();
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// ── Race condition protection (stale navigation cancellation) ──
|
|
789
|
+
|
|
790
|
+
describe("entry.tsx — race condition protection", () => {
|
|
791
|
+
it("rapid navigation discards stale loads — only latest wins", async () => {
|
|
792
|
+
await renderApp();
|
|
793
|
+
|
|
794
|
+
let resolveFirst: ((v: any) => void) | null = null;
|
|
795
|
+
|
|
796
|
+
// First navigation: hangs until we resolve it
|
|
797
|
+
mockLoadPage.mockImplementationOnce(() => {
|
|
798
|
+
return new Promise((resolve) => {
|
|
799
|
+
resolveFirst = resolve;
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
// Second navigation: resolves immediately
|
|
804
|
+
mockLoadPage.mockImplementationOnce(() => {
|
|
805
|
+
return Promise.resolve({
|
|
806
|
+
isMdx: false,
|
|
807
|
+
html: "<p>Page 2</p>",
|
|
808
|
+
frontmatter: { title: "Page 2", description: "" },
|
|
809
|
+
headings: [],
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
// Fire first navigation (will hang)
|
|
814
|
+
act(() => {
|
|
815
|
+
capturedShellProps.onNavigate("quickstart");
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// Fire second navigation immediately (should supersede first)
|
|
819
|
+
await act(async () => {
|
|
820
|
+
await capturedShellProps.onNavigate("index");
|
|
821
|
+
});
|
|
822
|
+
await act(async () => {
|
|
823
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// The page should show the second navigation's result
|
|
827
|
+
expect(capturedShellProps.pageHtml).toBe("<p>Page 2</p>");
|
|
828
|
+
|
|
829
|
+
// Resolve the first navigation (stale — should be discarded)
|
|
830
|
+
if (resolveFirst) {
|
|
831
|
+
resolveFirst({
|
|
832
|
+
isMdx: false,
|
|
833
|
+
html: "<p>Stale</p>",
|
|
834
|
+
frontmatter: { title: "Stale" },
|
|
835
|
+
headings: [],
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
await act(async () => {
|
|
839
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// Page should STILL show the second navigation's result, not the stale one
|
|
843
|
+
expect(capturedShellProps.pageHtml).toBe("<p>Page 2</p>");
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it("global styles apply overflow: clip to html, body, and #tome-root", async () => {
|
|
847
|
+
await renderApp();
|
|
848
|
+
|
|
849
|
+
// entry.tsx injects a <style> tag with overflow: clip on html, body, and #tome-root
|
|
850
|
+
const styleTags = document.querySelectorAll("style");
|
|
851
|
+
const globalStyle = Array.from(styleTags).find(
|
|
852
|
+
(s) => s.textContent?.includes("overflow: clip") && s.textContent?.includes("#tome-root"),
|
|
853
|
+
);
|
|
854
|
+
expect(globalStyle).not.toBeNull();
|
|
855
|
+
expect(globalStyle!.textContent).toContain("html, body");
|
|
856
|
+
expect(globalStyle!.textContent).toContain("overflow: clip");
|
|
857
|
+
expect(globalStyle!.textContent).toContain("#tome-root { height: 100%; overflow: clip; }");
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it("stale navigation does not update state or push history", async () => {
|
|
861
|
+
await renderApp();
|
|
862
|
+
|
|
863
|
+
// Reset pushState call count
|
|
864
|
+
(window.history.pushState as ReturnType<typeof vi.fn>).mockClear();
|
|
865
|
+
|
|
866
|
+
let resolveStale: ((v: any) => void) | null = null;
|
|
867
|
+
|
|
868
|
+
// First navigation: hangs
|
|
869
|
+
mockLoadPage.mockImplementationOnce(() => new Promise((resolve) => { resolveStale = resolve; }));
|
|
870
|
+
|
|
871
|
+
// Second navigation: resolves immediately
|
|
872
|
+
mockLoadPage.mockImplementationOnce(() => Promise.resolve({
|
|
873
|
+
isMdx: false,
|
|
874
|
+
html: "<p>Winner</p>",
|
|
875
|
+
frontmatter: { title: "Winner", description: "" },
|
|
876
|
+
headings: [],
|
|
877
|
+
}));
|
|
878
|
+
|
|
879
|
+
// Fire first (will hang)
|
|
880
|
+
act(() => { capturedShellProps.onNavigate("quickstart"); });
|
|
881
|
+
|
|
882
|
+
// Fire second (supersedes first)
|
|
883
|
+
await act(async () => { await capturedShellProps.onNavigate("index"); });
|
|
884
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
|
885
|
+
|
|
886
|
+
// Only one pushState call (from the second/winning navigation)
|
|
887
|
+
expect(window.history.pushState).toHaveBeenCalledTimes(1);
|
|
888
|
+
expect(capturedShellProps.pageHtml).toBe("<p>Winner</p>");
|
|
889
|
+
|
|
890
|
+
// Resolve the stale navigation — should be discarded
|
|
891
|
+
if (resolveStale) {
|
|
892
|
+
resolveStale({ isMdx: false, html: "<p>Stale</p>", frontmatter: { title: "Stale" }, headings: [] });
|
|
893
|
+
}
|
|
894
|
+
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
|
895
|
+
|
|
896
|
+
// Still only one pushState call, still showing winner
|
|
897
|
+
expect(window.history.pushState).toHaveBeenCalledTimes(1);
|
|
898
|
+
expect(capturedShellProps.pageHtml).toBe("<p>Winner</p>");
|
|
899
|
+
});
|
|
900
|
+
});
|
package/src/entry.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
computeEditUrl,
|
|
8
8
|
resolveInitialPageId,
|
|
9
9
|
detectCurrentVersion,
|
|
10
|
+
NavigationCancelledError,
|
|
10
11
|
type LoadedPage,
|
|
11
12
|
} from "./entry-helpers.js";
|
|
12
13
|
|
|
@@ -58,14 +59,13 @@ const MDX_COMPONENTS: Record<string, React.ComponentType<any>> = {
|
|
|
58
59
|
|
|
59
60
|
// ── CONTENT STYLES ───────────────────────────────────────
|
|
60
61
|
const contentStyles = `
|
|
61
|
-
@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,700&family=Fira+Code:wght@400;500;600&display=swap');
|
|
62
|
+
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bricolage+Grotesque:wght@300;400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,700&family=Fira+Code:wght@400;500;600&family=Bodoni+Moda:ital,wght@0,400;0,700;0,900;1,400&family=Space+Grotesk:wght@400;500;600;700&family=Source+Code+Pro:wght@400;500;600&family=Inter:wght@300;400;500;600;700&display=swap');
|
|
62
63
|
|
|
63
|
-
html, body { margin: 0; padding: 0; height: 100%; overflow:
|
|
64
|
-
#tome-root { height: 100%; overflow:
|
|
64
|
+
html, body { margin: 0; padding: 0; height: 100%; overflow: clip; }
|
|
65
|
+
#tome-root { height: 100%; overflow: clip; }
|
|
65
66
|
|
|
66
67
|
.tome-content h1 { display: none; }
|
|
67
|
-
.tome-content h2 { font-family: var(--font-body); font-size: 1.35em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em;
|
|
68
|
-
.tome-content h2::before { content: "#"; font-family: var(--font-heading); font-size: 1.2em; font-weight: 300; font-style: italic; color: var(--ac); opacity: 0.5; }
|
|
68
|
+
.tome-content h2 { font-family: var(--font-body); font-size: 1.35em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; letter-spacing: 0.01em; }
|
|
69
69
|
.tome-content h3 { font-family: var(--font-body); font-size: 1.15em; font-weight: 600; margin-top: 1.5em; margin-bottom: 0.5em; }
|
|
70
70
|
.tome-content h4 { font-family: var(--font-body); font-size: 1.05em; font-weight: 600; margin-top: 1.2em; margin-bottom: 0.5em; }
|
|
71
71
|
.tome-content p { color: var(--tx2); line-height: 1.8; margin-bottom: 1em; font-size: 14.5px; }
|
|
@@ -307,16 +307,38 @@ function App() {
|
|
|
307
307
|
const [pageData, setPageData] = useState<LoadedPage | null>(null);
|
|
308
308
|
const [loading, setLoading] = useState(true);
|
|
309
309
|
|
|
310
|
+
// Navigation counter for race condition protection — only the latest navigation wins
|
|
311
|
+
const navCounterRef = useRef(0);
|
|
312
|
+
|
|
310
313
|
const navigateTo = useCallback(async (id: string, opts?: { replace?: boolean; skipScroll?: boolean }) => {
|
|
314
|
+
// Increment counter — this navigation's ID
|
|
315
|
+
const navId = ++navCounterRef.current;
|
|
316
|
+
|
|
311
317
|
setLoading(true);
|
|
312
|
-
|
|
318
|
+
|
|
319
|
+
// Load the page BEFORE updating any state — on failure or cancellation, nothing changes
|
|
320
|
+
let data: LoadedPage | null;
|
|
321
|
+
try {
|
|
322
|
+
data = await loadPage(id, routes, loadPageModule);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
// If a newer navigation started while we were loading, bail silently
|
|
325
|
+
if (navCounterRef.current !== navId) return;
|
|
326
|
+
console.error(`[tome] Navigation failed for page: ${id}`, err);
|
|
327
|
+
data = null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// If a newer navigation started while we were loading, bail silently
|
|
331
|
+
if (navCounterRef.current !== navId) return;
|
|
332
|
+
|
|
333
|
+
// Only update state and push history after successful page load
|
|
313
334
|
const fullPath = pageIdToPath(id);
|
|
314
335
|
if (opts?.replace) {
|
|
315
336
|
window.history.replaceState(null, "", fullPath);
|
|
316
337
|
} else {
|
|
317
338
|
window.history.pushState(null, "", fullPath);
|
|
318
339
|
}
|
|
319
|
-
|
|
340
|
+
|
|
341
|
+
setCurrentPageId(id);
|
|
320
342
|
setPageData(data);
|
|
321
343
|
setLoading(false);
|
|
322
344
|
// Scroll to heading anchor if present, otherwise scroll to top
|
package/src/presets.test.ts
CHANGED
|
@@ -11,12 +11,14 @@ const TOKEN_KEYS = [
|
|
|
11
11
|
|
|
12
12
|
const FONT_KEYS = ["heading", "body", "code"] as const;
|
|
13
13
|
|
|
14
|
-
const PRESET_NAMES: PresetName[] = ["amber", "editorial"];
|
|
14
|
+
const PRESET_NAMES: PresetName[] = ["amber", "editorial", "cipher", "mint"];
|
|
15
15
|
|
|
16
16
|
describe("THEME_PRESETS", () => {
|
|
17
|
-
it("contains
|
|
17
|
+
it("contains all presets", () => {
|
|
18
18
|
expect(THEME_PRESETS).toHaveProperty("amber");
|
|
19
19
|
expect(THEME_PRESETS).toHaveProperty("editorial");
|
|
20
|
+
expect(THEME_PRESETS).toHaveProperty("cipher");
|
|
21
|
+
expect(THEME_PRESETS).toHaveProperty("mint");
|
|
20
22
|
});
|
|
21
23
|
|
|
22
24
|
for (const name of PRESET_NAMES) {
|
package/src/presets.ts
CHANGED
|
@@ -46,6 +46,36 @@ export const THEME_PRESETS = {
|
|
|
46
46
|
},
|
|
47
47
|
fonts: { heading: "Cormorant Garamond", body: "Bricolage Grotesque", code: "Fira Code" },
|
|
48
48
|
},
|
|
49
|
+
cipher: {
|
|
50
|
+
dark: {
|
|
51
|
+
bg:"#050508",sf:"#0c0c12",sfH:"#12121a",bd:"#1a1a25",
|
|
52
|
+
tx:"#d4ff00",tx2:"#8a90a0",txM:"#6a7080",
|
|
53
|
+
ac:"#6666ff",acD:"rgba(102,102,255,0.10)",acT:"#8080ff",
|
|
54
|
+
cdBg:"#08080e",cdTx:"#b0c870",sbBg:"#08080d",hdBg:"rgba(5,5,8,0.88)",
|
|
55
|
+
},
|
|
56
|
+
light: {
|
|
57
|
+
bg:"#f0f2f5",sf:"#ffffff",sfH:"#e8eaef",bd:"#d0d4db",
|
|
58
|
+
tx:"#0f1219",tx2:"#4a5060",txM:"#6a7080",
|
|
59
|
+
ac:"#2020cc",acD:"rgba(32,32,204,0.08)",acT:"#1a1aa8",
|
|
60
|
+
cdBg:"#e6e9ef",cdTx:"#2a3520",sbBg:"#ebedf2",hdBg:"rgba(240,242,245,0.90)",
|
|
61
|
+
},
|
|
62
|
+
fonts: { heading: "Bodoni Moda", body: "Space Grotesk", code: "Source Code Pro" },
|
|
63
|
+
},
|
|
64
|
+
mint: {
|
|
65
|
+
dark: {
|
|
66
|
+
bg:"#0d1117",sf:"#161b22",sfH:"#1c2129",bd:"#21262d",
|
|
67
|
+
tx:"#e6edf3",tx2:"#8b949e",txM:"#6e7681",
|
|
68
|
+
ac:"#0ea371",acD:"rgba(14,163,113,0.10)",acT:"#2dd4a0",
|
|
69
|
+
cdBg:"#0a0e14",cdTx:"#adbac7",sbBg:"#0d1117",hdBg:"rgba(13,17,23,0.88)",
|
|
70
|
+
},
|
|
71
|
+
light: {
|
|
72
|
+
bg:"#ffffff",sf:"#f6f8fa",sfH:"#eef1f5",bd:"#d8dee4",
|
|
73
|
+
tx:"#1f2328",tx2:"#59636e",txM:"#6e7681",
|
|
74
|
+
ac:"#0a7b53",acD:"rgba(10,123,83,0.07)",acT:"#087a50",
|
|
75
|
+
cdBg:"#f0f3f6",cdTx:"#24292f",sbBg:"#f6f8fa",hdBg:"rgba(255,255,255,0.90)",
|
|
76
|
+
},
|
|
77
|
+
fonts: { heading: "Inter", body: "Inter", code: "Fira Code" },
|
|
78
|
+
},
|
|
49
79
|
} as const satisfies Record<string, ThemePreset>;
|
|
50
80
|
|
|
51
81
|
export type PresetName = keyof typeof THEME_PRESETS;
|