@tomehq/theme 0.2.7 → 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/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) {
@@ -107,103 +124,166 @@ const contentStyles = `
107
124
  html:not(.dark) .shiki span[style*="color:#005CC5"] { color: #0349b4 !important; }
108
125
  `;
109
126
 
110
- // ── PAGE TYPES ────────────────────────────────────────────
111
- interface HtmlPage {
112
- isMdx: false;
113
- html: string;
114
- frontmatter: { title: string; description?: string; toc?: boolean; type?: string };
115
- headings: Array<{ depth: number; text: string; id: string }>;
116
- changelogEntries?: Array<{ version: string; date?: string; url?: string; sections: Array<{ type: string; items: string[] }> }>;
117
- }
127
+ // ── ROUTING HELPERS ──────────────────────────────────────
128
+ const basePath = (config.basePath || "/").replace(/\/$/, "");
118
129
 
119
- interface MdxPage {
120
- isMdx: true;
121
- component: React.ComponentType<{ components?: Record<string, React.ComponentType> }>;
122
- frontmatter: { title: string; description?: string; toc?: boolean; type?: string };
123
- headings: Array<{ depth: number; text: string; id: string }>;
130
+ function pathnameToPageId(pathname: string): string | null {
131
+ return _pathnameToPageId(pathname, basePath, routes);
124
132
  }
125
133
 
126
- type LoadedPage = HtmlPage | MdxPage;
127
-
128
- // ── PAGE LOADER ──────────────────────────────────────────
129
- async function loadPage(id: string): Promise<LoadedPage | null> {
130
- try {
131
- const route = routes.find((r: any) => r.id === id);
132
- const mod = await loadPageModule(id);
133
-
134
- if (route?.isMdx && mod.meta) {
135
- // TOM-8: MDX page — mod.default is the React component
136
- return {
137
- isMdx: true,
138
- component: mod.default,
139
- frontmatter: mod.meta.frontmatter,
140
- headings: mod.meta.headings,
141
- };
142
- }
143
-
144
- // Regular .md page — mod.default is { html, frontmatter, headings }
145
- if (!mod.default) return null;
146
-
147
- // TOM-49: Changelog page type
148
- if (mod.isChangelog && mod.changelogEntries) {
149
- return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
150
- }
151
-
152
- return { isMdx: false, ...mod.default };
153
- } catch (err) {
154
- console.error(`Failed to load page: ${id}`, err);
155
- return null;
156
- }
134
+ function pageIdToPath(id: string): string {
135
+ return _pageIdToPath(id, basePath, routes);
157
136
  }
158
137
 
159
138
  // ── APP ──────────────────────────────────────────────────
160
139
  function App() {
161
- const [currentPageId, setCurrentPageId] = useState(() => {
162
- const hash = window.location.hash.slice(1);
163
- if (hash && routes.some((r: any) => r.id === hash)) return hash;
164
- return routes[0]?.id || "index";
165
- });
140
+ const [currentPageId, setCurrentPageId] = useState(() =>
141
+ resolveInitialPageId(
142
+ window.location.pathname,
143
+ window.location.hash,
144
+ routes,
145
+ basePath,
146
+ _pathnameToPageId,
147
+ )
148
+ );
166
149
 
167
150
  const [pageData, setPageData] = useState<LoadedPage | null>(null);
168
151
  const [loading, setLoading] = useState(true);
169
152
 
170
- const navigateTo = useCallback(async (id: string) => {
153
+ const navigateTo = useCallback(async (id: string, opts?: { replace?: boolean; skipScroll?: boolean }) => {
171
154
  setLoading(true);
172
155
  setCurrentPageId(id);
173
- window.location.hash = id;
174
- const data = await loadPage(id);
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);
175
163
  setPageData(data);
176
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
+ }
177
177
  }, []);
178
178
 
179
- useEffect(() => { navigateTo(currentPageId); }, []);
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
+ }, []);
180
191
 
192
+ // Listen for browser back/forward navigation
181
193
  useEffect(() => {
182
- const onHashChange = () => {
183
- const hash = window.location.hash.slice(1);
184
- // Only navigate if hash matches a known route ID (ignore heading anchors)
185
- if (hash && hash !== currentPageId && routes.some((r: any) => r.id === hash)) {
186
- navigateTo(hash);
194
+ const onPopState = () => {
195
+ const id = pathnameToPageId(window.location.pathname);
196
+ if (id && id !== currentPageId) {
197
+ navigateTo(id, { replace: true, skipScroll: true });
187
198
  }
188
199
  };
189
- window.addEventListener("hashchange", onHashChange);
190
- return () => window.removeEventListener("hashchange", onHashChange);
200
+ window.addEventListener("popstate", onPopState);
201
+ return () => window.removeEventListener("popstate", onPopState);
191
202
  }, [currentPageId, navigateTo]);
192
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
+
193
264
  const allPages = routes.map((r: any) => ({
194
265
  id: r.id,
195
266
  title: r.frontmatter.title,
196
267
  description: r.frontmatter.description,
197
268
  }));
198
269
 
199
- // TOM-48: Compute edit URL for current page
270
+ // Compute current version from route metadata
200
271
  const currentRoute = routes.find((r: any) => r.id === currentPageId);
201
- let editUrl: string | undefined;
202
- if (config.editLink && currentRoute?.filePath) {
203
- const { repo, branch = "main", dir = "" } = config.editLink;
204
- const dirPrefix = dir ? `${dir.replace(/\/$/, "")}/` : "";
205
- editUrl = `https://github.com/${repo}/edit/${branch}/${dirPrefix}${currentRoute.filePath}`;
206
- }
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
+ }, []);
207
287
 
208
288
  return (
209
289
  <>
@@ -225,6 +305,9 @@ function App() {
225
305
  onNavigate={navigateTo}
226
306
  allPages={allPages}
227
307
  docContext={docContext}
308
+ versioning={versions || undefined}
309
+ currentVersion={currentVersion}
310
+ basePath={basePath}
228
311
  />
229
312
  </>
230
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
+ }