@tomehq/theme 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/entry.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  entry_default
3
- } from "./chunk-2APCPR2Y.js";
3
+ } from "./chunk-YXKONM3A.js";
4
4
  export {
5
5
  entry_default as default
6
6
  };
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  Shell,
4
4
  THEME_PRESETS,
5
5
  entry_default
6
- } from "./chunk-2APCPR2Y.js";
6
+ } from "./chunk-YXKONM3A.js";
7
7
  export {
8
8
  AiChat,
9
9
  entry_default as App,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomehq/theme",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
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.2.8",
13
- "@tomehq/core": "0.2.8"
12
+ "@tomehq/components": "0.3.1",
13
+ "@tomehq/core": "0.3.1"
14
14
  },
15
15
  "peerDependencies": {
16
16
  "react": "^18.0.0 || ^19.0.0",
package/src/Shell.tsx CHANGED
@@ -359,6 +359,8 @@ export function Shell({
359
359
  const isOldVersion = versioning && currentVersion && currentVersion !== versioning.current;
360
360
  const [expanded, setExpanded] = useState<string[]>(navigation.map(n => n.section));
361
361
  const contentRef = useRef<HTMLDivElement>(null);
362
+ const htmlContentRef = useRef<HTMLDivElement>(null);
363
+ const lastHtmlRef = useRef<string>("");
362
364
  const [wide, setWide] = useState(() => typeof window !== "undefined" && window.innerWidth > 1100);
363
365
 
364
366
  const preset = (config.theme?.preset || "amber") as PresetName;
@@ -531,6 +533,17 @@ export function Shell({
531
533
  // Reset active heading when page changes
532
534
  useEffect(() => { setActiveHeadingId(""); }, [currentPageId]);
533
535
 
536
+ // Set HTML content via ref so React doesn't re-set innerHTML on re-renders
537
+ // (scroll-spy state changes would otherwise destroy client-side mermaid SVGs)
538
+ useEffect(() => {
539
+ if (!htmlContentRef.current || !pageHtml) return;
540
+ const stripped = pageHtml.replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, "");
541
+ if (stripped !== lastHtmlRef.current) {
542
+ htmlContentRef.current.innerHTML = stripped;
543
+ lastHtmlRef.current = stripped;
544
+ }
545
+ }, [pageHtml]);
546
+
534
547
  // Smooth scroll handler for TOC links
535
548
  const scrollToHeading = useCallback((e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
536
549
  e.preventDefault();
@@ -584,7 +597,7 @@ export function Shell({
584
597
  display: "flex", alignItems: "center", justifyContent: "center", gap: 12,
585
598
  background: "var(--ac)", color: "#fff", padding: "8px 16px",
586
599
  fontSize: 13, fontFamily: "var(--font-body)", fontWeight: 500, textAlign: "center",
587
- width: "100%", boxSizing: "border-box",
600
+ width: "100vw", boxSizing: "border-box",
588
601
  }}>
589
602
  {config.banner.link ? (
590
603
  <a
@@ -977,7 +990,7 @@ export function Shell({
977
990
  ) : (
978
991
  <div
979
992
  className="tome-content"
980
- dangerouslySetInnerHTML={{ __html: (pageHtml || "").replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, "") }}
993
+ ref={htmlContentRef}
981
994
  />
982
995
  )}
983
996
  </div>
package/src/entry.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useCallback } from "react";
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
2
  import { createRoot } from "react-dom/client";
3
3
  import { Shell } from "./Shell.js";
4
4
  import { pathnameToPageId as _pathnameToPageId, pageIdToPath as _pageIdToPath } from "./routing.js";
@@ -75,6 +75,27 @@ const contentStyles = `
75
75
  .tome-mermaid { margin: 1.2em 0; text-align: center; overflow-x: auto; }
76
76
  .tome-mermaid svg { max-width: 100%; height: auto; overflow: visible; }
77
77
  .tome-mermaid svg .nodeLabel { overflow: visible; white-space: nowrap; }
78
+ /* Ensure mermaid text meets WCAG AA contrast in light mode */
79
+ /* Mermaid v11 uses foreignObject with inline-styled spans — !important needed */
80
+ html:not(.dark) .tome-mermaid svg .nodeLabel,
81
+ html:not(.dark) .tome-mermaid svg .nodeLabel span,
82
+ html:not(.dark) .tome-mermaid svg .nodeLabel div,
83
+ html:not(.dark) .tome-mermaid svg foreignObject div,
84
+ html:not(.dark) .tome-mermaid svg foreignObject span { color: #1a1a1a !important; }
85
+ html:not(.dark) .tome-mermaid svg .edgeLabel,
86
+ html:not(.dark) .tome-mermaid svg .edgeLabel span { color: #333 !important; }
87
+ html:not(.dark) .tome-mermaid svg text { fill: #1a1a1a !important; }
88
+ html:not(.dark) .tome-mermaid svg .node rect,
89
+ html:not(.dark) .tome-mermaid svg .node polygon { stroke: #555 !important; }
90
+ /* Dark mode: force bright text in mermaid nodes for readability */
91
+ html.dark .tome-mermaid svg .nodeLabel,
92
+ html.dark .tome-mermaid svg .nodeLabel span,
93
+ html.dark .tome-mermaid svg .nodeLabel div,
94
+ html.dark .tome-mermaid svg foreignObject div,
95
+ html.dark .tome-mermaid svg foreignObject span { color: #f0f0f0 !important; }
96
+ html.dark .tome-mermaid svg .edgeLabel,
97
+ html.dark .tome-mermaid svg .edgeLabel span { color: #ddd !important; }
98
+ html.dark .tome-mermaid svg text { fill: #f0f0f0 !important; }
78
99
 
79
100
  /* Mobile responsive content */
80
101
  @media (max-width: 767px) {
@@ -135,17 +156,22 @@ function pageIdToPath(id: string): string {
135
156
  return _pageIdToPath(id, basePath, routes);
136
157
  }
137
158
 
159
+ // ── EAGER INITIAL PAGE LOAD ──────────────────────────────
160
+ // Start loading the initial page at module scope — before React mounts —
161
+ // so the data is ready (or nearly ready) by the time App first renders.
162
+ // This eliminates the "Loading..." flash on production.
163
+ const _initialPageId = resolveInitialPageId(
164
+ window.location.pathname,
165
+ window.location.hash,
166
+ routes,
167
+ basePath,
168
+ _pathnameToPageId,
169
+ );
170
+ const _initialPagePromise = loadPage(_initialPageId, routes, loadPageModule);
171
+
138
172
  // ── APP ──────────────────────────────────────────────────
139
173
  function App() {
140
- const [currentPageId, setCurrentPageId] = useState(() =>
141
- resolveInitialPageId(
142
- window.location.pathname,
143
- window.location.hash,
144
- routes,
145
- basePath,
146
- _pathnameToPageId,
147
- )
148
- );
174
+ const [currentPageId, setCurrentPageId] = useState(_initialPageId);
149
175
 
150
176
  const [pageData, setPageData] = useState<LoadedPage | null>(null);
151
177
  const [loading, setLoading] = useState(true);
@@ -176,7 +202,7 @@ function App() {
176
202
  }
177
203
  }, []);
178
204
 
179
- // Initial page load
205
+ // Initial page load — use the eagerly-started promise from module scope
180
206
  useEffect(() => {
181
207
  // If user landed on a legacy hash URL, redirect to clean path
182
208
  const hash = window.location.hash.slice(1);
@@ -185,7 +211,13 @@ function App() {
185
211
  window.history.replaceState(null, "", fullPath);
186
212
  navigateTo(hash, { replace: true });
187
213
  } else {
188
- navigateTo(currentPageId, { replace: true, skipScroll: true });
214
+ // Use the pre-fetched promise instead of starting a new load
215
+ const fullPath = pageIdToPath(currentPageId);
216
+ window.history.replaceState(null, "", fullPath);
217
+ _initialPagePromise.then((data) => {
218
+ setPageData(data);
219
+ setLoading(false);
220
+ });
189
221
  }
190
222
  }, []);
191
223
 
@@ -201,7 +233,30 @@ function App() {
201
233
  return () => window.removeEventListener("popstate", onPopState);
202
234
  }, [currentPageId, navigateTo]);
203
235
 
204
- // Mermaid diagram rendering: load from CDN and render .tome-mermaid elements
236
+ // Mermaid diagram rendering: load from CDN and render .tome-mermaid elements.
237
+ // Also re-renders when theme changes (dark ↔ light) so colors stay correct.
238
+ const mermaidModuleRef = useRef<any>(null);
239
+ const [mermaidTheme, setMermaidTheme] = useState(() => {
240
+ if (typeof document === "undefined") return "light";
241
+ // Check the class first (already set), then fall back to config + system preference
242
+ // to avoid a white flash before Shell syncs the dark class onto <html>
243
+ if (document.documentElement.classList.contains("dark")) return "dark";
244
+ const mode = config.theme?.mode || "auto";
245
+ if (mode === "dark") return "dark";
246
+ if (mode === "light") return "light";
247
+ return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
248
+ });
249
+
250
+ // Watch for dark class changes on <html> to trigger mermaid re-render
251
+ useEffect(() => {
252
+ const observer = new MutationObserver(() => {
253
+ const isDark = document.documentElement.classList.contains("dark");
254
+ setMermaidTheme(isDark ? "dark" : "light");
255
+ });
256
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
257
+ return () => observer.disconnect();
258
+ }, []);
259
+
205
260
  useEffect(() => {
206
261
  const els = document.querySelectorAll(".tome-mermaid[data-mermaid]");
207
262
  if (els.length === 0) return;
@@ -211,11 +266,12 @@ function App() {
211
266
 
212
267
  (async () => {
213
268
  try {
214
- // Load mermaid from CDN (ESM) — works in all browsers, no bundler dependency
215
- const { default: mermaid } = await import(/* @vite-ignore */ MERMAID_CDN);
269
+ if (!mermaidModuleRef.current) {
270
+ mermaidModuleRef.current = (await import(/* @vite-ignore */ MERMAID_CDN)).default;
271
+ }
272
+ const mermaid = mermaidModuleRef.current;
216
273
  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
274
+ const isDark = mermaidTheme === "dark";
219
275
  const resolvedFont = getComputedStyle(document.documentElement).getPropertyValue("--font-body").trim() || "sans-serif";
220
276
  mermaid.initialize({
221
277
  startOnLoad: false,
@@ -226,22 +282,14 @@ function App() {
226
282
 
227
283
  for (let i = 0; i < els.length; i++) {
228
284
  const el = els[i] as HTMLElement;
229
- if (el.querySelector("svg")) continue; // already rendered
230
285
  const encoded = el.getAttribute("data-mermaid");
231
286
  if (!encoded) continue;
232
287
  try {
233
288
  const code = atob(encoded);
289
+ // Render new SVG off-screen first, then swap — avoids white flash on theme change
234
290
  const { svg } = await mermaid.render(`tome-mermaid-${i}-${Date.now()}`, code);
235
291
  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: { html: true, svg: true, svgFilters: true } });
241
- } catch {
242
- // DOMPurify unavailable — render without sanitization (acceptable for trusted content)
243
- el.innerHTML = svg;
244
- }
292
+ el.innerHTML = svg;
245
293
  }
246
294
  } catch (err) {
247
295
  console.warn("[tome] Mermaid render failed:", err);
@@ -259,7 +307,7 @@ function App() {
259
307
  })();
260
308
 
261
309
  return () => { cancelled = true; };
262
- }, [pageData, loading]);
310
+ }, [pageData, loading, mermaidTheme]);
263
311
 
264
312
  const allPages = routes.map((r: any) => ({
265
313
  id: r.id,
@@ -272,9 +320,10 @@ function App() {
272
320
  const currentVersion = detectCurrentVersion(currentRoute, versions);
273
321
  const editUrl = computeEditUrl(config.editLink, currentRoute?.filePath);
274
322
 
275
- // KaTeX CSS: inject stylesheet when math is enabled
323
+ // KaTeX CSS: inject stylesheet when math is enabled or math placeholders exist
276
324
  useEffect(() => {
277
- if (!(config as any).math) return;
325
+ const hasMathPlaceholders = document.querySelectorAll(".tome-math[data-math]").length > 0;
326
+ if (!(config as any).math && !hasMathPlaceholders) return;
278
327
  const id = "tome-katex-css";
279
328
  if (document.getElementById(id)) return;
280
329
  const link = document.createElement("link");
@@ -283,7 +332,41 @@ function App() {
283
332
  link.href = "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css";
284
333
  link.crossOrigin = "anonymous";
285
334
  document.head.appendChild(link);
286
- }, []);
335
+ }, [pageData, loading]);
336
+
337
+ // Client-side KaTeX rendering for MDX math placeholders (.tome-math[data-math])
338
+ useEffect(() => {
339
+ const els = document.querySelectorAll(".tome-math[data-math]");
340
+ if (els.length === 0) return;
341
+ let cancelled = false;
342
+
343
+ const KATEX_CDN = "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.mjs";
344
+
345
+ (async () => {
346
+ try {
347
+ const katex = (await import(/* @vite-ignore */ KATEX_CDN)).default;
348
+ if (cancelled) return;
349
+ for (const el of els) {
350
+ const encoded = el.getAttribute("data-math");
351
+ if (!encoded) continue;
352
+ try {
353
+ const tex = atob(encoded);
354
+ const isBlock = el.classList.contains("tome-math-block");
355
+ katex.render(tex, el as HTMLElement, {
356
+ displayMode: isBlock,
357
+ throwOnError: false,
358
+ });
359
+ } catch (err) {
360
+ console.warn("[tome] KaTeX render failed:", err);
361
+ }
362
+ }
363
+ } catch (err) {
364
+ console.warn("[tome] Failed to load KaTeX from CDN:", err);
365
+ }
366
+ })();
367
+
368
+ return () => { cancelled = true; };
369
+ }, [pageData, loading]);
287
370
 
288
371
  return (
289
372
  <>
@@ -292,10 +375,10 @@ function App() {
292
375
  config={config}
293
376
  navigation={navigation}
294
377
  currentPageId={currentPageId}
295
- pageHtml={!pageData?.isMdx ? (loading ? "<p>Loading...</p>" : pageData?.html || "<p>Page not found</p>") : undefined}
378
+ pageHtml={!pageData?.isMdx ? (loading ? "" : pageData?.html || "<p>Page not found</p>") : undefined}
296
379
  pageComponent={pageData?.isMdx ? pageData.component : undefined}
297
380
  mdxComponents={MDX_COMPONENTS}
298
- pageTitle={pageData?.frontmatter.title || (loading ? "Loading..." : "Not Found")}
381
+ pageTitle={pageData?.frontmatter.title || (loading ? "" : "Not Found")}
299
382
  pageDescription={pageData?.frontmatter.description}
300
383
  headings={pageData?.headings || []}
301
384
  tocEnabled={pageData?.frontmatter.toc !== false}