@tomehq/theme 0.3.1 → 0.3.3

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/{chunk-YXKONM3A.js → chunk-MSXVVBDW.js} +493 -143
  3. package/dist/entry.js +1 -1
  4. package/dist/index.d.ts +37 -1
  5. package/dist/index.js +1 -1
  6. package/package.json +5 -5
  7. package/src/Shell.test.tsx +405 -0
  8. package/src/Shell.tsx +248 -24
  9. package/src/__virtual_stubs/config.ts +2 -0
  10. package/src/__virtual_stubs/doc-context.ts +2 -0
  11. package/src/__virtual_stubs/overrides.ts +2 -0
  12. package/src/__virtual_stubs/page-loader.ts +4 -0
  13. package/src/__virtual_stubs/routes.ts +5 -0
  14. package/src/entry-helpers.test.ts +76 -0
  15. package/src/entry-helpers.ts +18 -1
  16. package/src/entry.test.tsx +695 -0
  17. package/src/entry.tsx +179 -4
  18. package/src/global.d.ts +11 -0
  19. package/vitest.config.ts +31 -1
  20. package/dist/chunk-2APCPR2Y.js +0 -2110
  21. package/dist/chunk-37JI6XGT.js +0 -1720
  22. package/dist/chunk-3A2LPGUL.js +0 -1991
  23. package/dist/chunk-3I2QTWTW.js +0 -1948
  24. package/dist/chunk-45M5UIAB.js +0 -2110
  25. package/dist/chunk-462AGU3S.js +0 -1959
  26. package/dist/chunk-7MUTU5D4.js +0 -1720
  27. package/dist/chunk-ABNPB6BB.js +0 -2133
  28. package/dist/chunk-BZGWSKT2.js +0 -573
  29. package/dist/chunk-CMQCNCSY.js +0 -2127
  30. package/dist/chunk-CTPOZMMK.js +0 -1703
  31. package/dist/chunk-DO544M3G.js +0 -1702
  32. package/dist/chunk-DPKZBFQP.js +0 -1777
  33. package/dist/chunk-EK7PZUEB.js +0 -2147
  34. package/dist/chunk-FMOLIHQF.js +0 -2182
  35. package/dist/chunk-FWBTK5TL.js +0 -1444
  36. package/dist/chunk-GDQIBNX5.js +0 -1962
  37. package/dist/chunk-GHQ2MODM.js +0 -2127
  38. package/dist/chunk-GR2WCRGK.js +0 -2182
  39. package/dist/chunk-HNLKDQ64.js +0 -2139
  40. package/dist/chunk-INUMUXN5.js +0 -2095
  41. package/dist/chunk-IW3NHNOQ.js +0 -2187
  42. package/dist/chunk-JA4PMX6M.js +0 -1500
  43. package/dist/chunk-JSPFS7G5.js +0 -2102
  44. package/dist/chunk-JZRT4WNC.js +0 -1441
  45. package/dist/chunk-KQBY2JDB.js +0 -2112
  46. package/dist/chunk-LIMYFTPC.js +0 -1468
  47. package/dist/chunk-MEP7P6A7.js +0 -1500
  48. package/dist/chunk-NOZBIES7.js +0 -1948
  49. package/dist/chunk-O4GH3KYX.js +0 -1712
  50. package/dist/chunk-OEXM3BEC.js +0 -1702
  51. package/dist/chunk-Q7PYTVW3.js +0 -1771
  52. package/dist/chunk-QCWZYABW.js +0 -1468
  53. package/dist/chunk-RDF25WB2.js +0 -2085
  54. package/dist/chunk-RKTT3ZEX.js +0 -1500
  55. package/dist/chunk-S47BRMNQ.js +0 -1715
  56. package/dist/chunk-S4ZH5F56.js +0 -1949
  57. package/dist/chunk-SRD7NJHS.js +0 -1949
  58. package/dist/chunk-SWFYJO5H.js +0 -2187
  59. package/dist/chunk-TQDWPSTO.js +0 -2087
  60. package/dist/chunk-TTRXRPP6.js +0 -1941
  61. package/dist/chunk-UKYFJSUA.js +0 -509
  62. package/dist/chunk-VKEQHP2E.js +0 -2133
  63. package/dist/chunk-VUT2FMSI.js +0 -1937
  64. package/dist/chunk-VVCC5JHK.js +0 -1949
  65. package/dist/chunk-W732TVBK.js +0 -1944
  66. package/dist/chunk-X4VQYPKO.js +0 -1768
  67. package/dist/chunk-YZ3P3TNS.js +0 -1760
package/src/Shell.tsx CHANGED
@@ -212,6 +212,27 @@ const GlobeIcon = () => <Icon d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3.6 9h16.
212
212
  // ── TOP NAV EXTERNAL LINK ICON ────────────────────────────
213
213
  const ExtLinkIcon = () => <Icon d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3" size={11} />;
214
214
 
215
+ // ── SOCIAL LINK ICONS ──────────────────────────────────
216
+ const SOCIAL_ICON_PATHS: Record<string, string> = {
217
+ github: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z",
218
+ twitter: "M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865l8.875 11.633Z",
219
+ discord: "M13.545 2.907a13.227 13.227 0 00-3.257-1.011.05.05 0 00-.052.025c-.141.25-.297.577-.406.833a12.19 12.19 0 00-3.658 0 8.258 8.258 0 00-.412-.833.051.051 0 00-.052-.025c-1.125.194-2.22.534-3.257 1.011a.041.041 0 00-.021.018C.356 6.024-.213 9.047.066 12.032c.001.014.01.028.021.037a13.276 13.276 0 003.995 2.02.05.05 0 00.056-.019c.308-.42.582-.863.818-1.329a.05.05 0 00-.028-.07 8.735 8.735 0 01-1.248-.595.05.05 0 01-.005-.083c.084-.063.168-.129.248-.195a.05.05 0 01.051-.007c2.619 1.196 5.454 1.196 8.041 0a.052.052 0 01.053.007c.08.066.164.132.248.195a.051.051 0 01-.004.085c-.399.232-.813.431-1.249.594a.05.05 0 00-.03.07c.24.465.515.909.817 1.329a.05.05 0 00.056.019 13.235 13.235 0 004.001-2.02.049.049 0 00.021-.037c.334-3.451-.559-6.449-2.366-9.106a.034.034 0 00-.02-.019z",
220
+ linkedin: "M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854V1.146zm4.943 12.248V6.169H2.542v7.225h2.401zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248-.822 0-1.359.54-1.359 1.248 0 .694.521 1.248 1.327 1.248h.016zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016a5.54 5.54 0 01.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225h2.4z",
221
+ youtube: "M8.051 1.999h.089c.822.003 4.987.033 6.11.335a2.01 2.01 0 011.415 1.42c.101.38.172.883.22 1.402l.01.104.022.26.008.104c.065.914.073 1.77.074 1.957v.075c-.001.194-.01 1.108-.082 2.06l-.008.105-.009.104c-.05.572-.124 1.14-.235 1.558a2.007 2.007 0 01-1.415 1.42c-1.16.312-5.569.334-6.18.335h-.142c-.309 0-1.587-.006-2.927-.052l-.17-.006-.087-.004-.171-.007-.171-.007c-1.11-.049-2.167-.128-2.654-.26a2.007 2.007 0 01-1.415-1.419c-.111-.417-.185-.986-.235-1.558L.09 9.82l-.008-.104A31.4 31.4 0 010 7.68v-.123c.002-.215.01-.958.064-1.778l.007-.103.003-.052.008-.104.022-.26.01-.104c.048-.519.119-1.023.22-1.402a2.007 2.007 0 011.415-1.42c.487-.13 1.544-.21 2.654-.26l.17-.007.172-.006.086-.003.171-.007A99.788 99.788 0 017.858 2h.193zM6.4 5.209v4.818l4.157-2.408L6.4 5.209z",
222
+ mastodon: "M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765 2.79.765.504 1.783.504 5.253c-.005.995-.01 2.19.013 3.44.075 4.21.56 8.354 3.383 9.386 1.302.476 2.418.576 3.317.507 1.628-.125 2.541-.8 2.541-.8l-.054-1.182s-1.163.366-2.47.322c-1.293-.044-2.658-.138-2.867-1.716a3.23 3.23 0 01-.028-.465s1.27.31 2.879.384c.984.045 1.905-.058 2.842-.17zM13 8.59V5.319c0-.67-.17-1.2-.507-1.592-.348-.4-.806-.605-1.373-.605-.656 0-1.154.252-1.486.756L9.2 4.595l-.434-.717c-.332-.504-.83-.756-1.486-.756-.567 0-1.025.204-1.373.605-.338.392-.507.923-.507 1.592V8.59h1.69V5.468c0-.67.285-1.012.855-1.012.63 0 .946.404.946 1.204V7.11h1.682V5.66c0-.8.316-1.204.946-1.204.57 0 .855.342.855 1.012V8.59H13z",
223
+ bluesky: "M3.468 1.948C5.303 3.325 7.276 6.118 8 7.616c.724-1.498 2.697-4.29 4.532-5.668C13.855 1.013 16 .638 16 3.14c0 .5-.286 4.2-.454 4.8-.585 2.093-2.716 2.628-4.544 2.305 3.195.564 4.007 2.433 2.25 4.302-3.337 3.548-4.8-1.244-5.252-2.547 0 0-.116-.334-.166-.334h.332C8.166 11.666 8.05 12 8.05 12c-.452 1.303-1.916 6.095-5.252 2.547-1.756-1.869-.946-3.738 2.25-4.302-1.829.323-3.96-.212-4.544-2.305C.336 7.34.05 3.64.05 3.14.05.638 2.195 1.013 3.468 1.948z",
224
+ };
225
+
226
+ const SocialIcon = ({ platform, customIcon }: { platform: string; customIcon?: string }) => {
227
+ const d = platform === "custom" && customIcon ? customIcon : SOCIAL_ICON_PATHS[platform];
228
+ if (!d) return null;
229
+ return (
230
+ <svg width={16} height={16} viewBox="0 0 16 16" fill="currentColor">
231
+ <path d={d} />
232
+ </svg>
233
+ );
234
+ };
235
+
215
236
  // ── SHELL COMPONENT ──────────────────────────────────────
216
237
  export interface VersioningInfo {
217
238
  current: string;
@@ -222,6 +243,7 @@ export interface I18nInfo {
222
243
  defaultLocale: string;
223
244
  locales: string[];
224
245
  localeNames?: Record<string, string>;
246
+ localeDirs?: Record<string, "ltr" | "rtl">;
225
247
  }
226
248
 
227
249
  // ── CHANGELOG VIEW (TOM-49) ─────────────────────────────
@@ -290,6 +312,34 @@ function ChangelogView({ entries }: { entries: ChangelogViewEntry[] }) {
290
312
  );
291
313
  }
292
314
 
315
+ // ── BREADCRUMBS ─────────────────────────────────────────
316
+ type BreadcrumbItem = { label: string; href: string | null };
317
+
318
+ function getBreadcrumbs(
319
+ navigation: Array<{ section: string; pages: Array<{ title: string; id: string; urlPath: string }> }>,
320
+ currentPageId: string,
321
+ pageTitle: string,
322
+ ): BreadcrumbItem[] {
323
+ if (currentPageId === "index") return [];
324
+
325
+ for (const section of navigation) {
326
+ const found = section.pages.find(p => p.id === currentPageId);
327
+ if (found) {
328
+ const crumbs: BreadcrumbItem[] = [];
329
+ // Section label — link to first page in section
330
+ const firstPage = section.pages[0];
331
+ crumbs.push({
332
+ label: section.section,
333
+ href: firstPage ? firstPage.urlPath : null,
334
+ });
335
+ // Current page (last crumb, not a link)
336
+ crumbs.push({ label: pageTitle, href: null });
337
+ return crumbs;
338
+ }
339
+ }
340
+ return [];
341
+ }
342
+
293
343
  interface ShellProps {
294
344
  config: {
295
345
  name: string;
@@ -299,11 +349,12 @@ interface ShellProps {
299
349
  toc?: { enabled?: boolean; depth?: number };
300
350
  topNav?: Array<{ label: string; href: string }>;
301
351
  banner?: { text: string; link?: string; dismissible?: boolean };
352
+ socialLinks?: Array<{ platform: string; url: string; label?: string; icon?: string }>;
302
353
  [key: string]: unknown;
303
354
  };
304
355
  navigation: Array<{
305
356
  section: string;
306
- pages: Array<{ title: string; id: string; urlPath: string; icon?: string }>;
357
+ pages: Array<{ title: string; id: string; urlPath: string; icon?: string; badge?: { text: string; variant: string } }>;
307
358
  }>;
308
359
  currentPageId: string;
309
360
  pageHtml?: string;
@@ -316,6 +367,16 @@ interface ShellProps {
316
367
  editUrl?: string;
317
368
  lastUpdated?: string;
318
369
  changelogEntries?: Array<{ version: string; date?: string; url?: string; sections: Array<{ type: string; items: string[] }> }>;
370
+ apiManifest?: any;
371
+ apiBaseUrl?: string;
372
+ apiPlayground?: boolean;
373
+ apiAuth?: { type: "bearer" | "apiKey"; header?: string };
374
+ ApiReferenceComponent?: React.ComponentType<{
375
+ manifest: any;
376
+ baseUrl?: string;
377
+ showPlayground?: boolean;
378
+ playgroundAuth?: { type: "bearer" | "apiKey"; header?: string };
379
+ }>;
319
380
  onNavigate: (id: string) => void;
320
381
  allPages: Array<{ id: string; title: string; description?: string }>;
321
382
  versioning?: VersioningInfo;
@@ -324,13 +385,28 @@ interface ShellProps {
324
385
  currentLocale?: string;
325
386
  docContext?: Array<{ id: string; title: string; content: string }>;
326
387
  basePath?: string;
388
+ isDraft?: boolean;
389
+ dir?: "ltr" | "rtl";
390
+ overrides?: {
391
+ Header?: React.ComponentType<any>;
392
+ Footer?: React.ComponentType<any>;
393
+ Sidebar?: React.ComponentType<any>;
394
+ Toc?: React.ComponentType<any>;
395
+ PageFooter?: React.ComponentType<any>;
396
+ };
327
397
  }
328
398
 
329
399
  export function Shell({
330
400
  config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents,
331
- pageTitle, pageDescription, headings, tocEnabled = true, editUrl, lastUpdated, changelogEntries, onNavigate, allPages,
332
- versioning, currentVersion, i18n, currentLocale, docContext, basePath = "",
401
+ pageTitle, pageDescription, headings, tocEnabled = true, editUrl, lastUpdated, changelogEntries,
402
+ apiManifest, apiBaseUrl, apiPlayground, apiAuth, ApiReferenceComponent, onNavigate, allPages,
403
+ versioning, currentVersion, i18n, currentLocale, docContext, basePath = "", isDraft, dir: dirProp, overrides,
333
404
  }: ShellProps) {
405
+ // RTL support: resolve text direction from prop, i18n.localeDirs, or default to "ltr"
406
+ const resolvedLocale = currentLocale || i18n?.defaultLocale || "en";
407
+ const dir: "ltr" | "rtl" = dirProp || i18n?.localeDirs?.[resolvedLocale] || "ltr";
408
+ const isRtl = dir === "rtl";
409
+
334
410
  const themeMode = config.theme?.mode || "auto";
335
411
 
336
412
  // TOM-12: Initialize dark mode from config.theme.mode + system preference
@@ -571,6 +647,9 @@ export function Shell({
571
647
  const prev = idx > 0 ? allNavPages[idx - 1] : null;
572
648
  const next = idx < allNavPages.length - 1 ? allNavPages[idx + 1] : null;
573
649
 
650
+ // Breadcrumbs
651
+ const breadcrumbs = getBreadcrumbs(navigation, currentPageId, pageTitle);
652
+
574
653
  const togSec = (s: string) => setExpanded(p => p.includes(s) ? p.filter(x => x !== s) : [...p, s]);
575
654
 
576
655
  const cssVars: Record<string, string> = {
@@ -590,7 +669,7 @@ export function Shell({
590
669
  const bannerIsInternal = bannerLink ? (bannerLink.startsWith("#") || (basePath && bannerLink.startsWith(basePath + "/"))) : false;
591
670
 
592
671
  return (
593
- <div className="tome-grain" style={{ ...cssVars as React.CSSProperties, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", minHeight: "100vh", overflow: "hidden" }}>
672
+ <div dir={dir} className="tome-grain" style={{ ...cssVars as React.CSSProperties, color: "var(--tx)", background: "var(--bg)", fontFamily: "var(--font-body)", minHeight: "100vh", overflow: "hidden" }}>
594
673
  {/* Banner */}
595
674
  {config.banner?.text && !bannerDismissed && (
596
675
  <div style={{
@@ -653,7 +732,7 @@ export function Shell({
653
732
  />
654
733
  ) : null}
655
734
 
656
- <div style={{ display: "flex", flex: 1, height: config.banner?.text && !bannerDismissed ? "calc(100vh - 32px)" : "100vh" }}>
735
+ <div style={{ display: "flex", flexDirection: isRtl ? "row-reverse" : "row", flex: 1, height: config.banner?.text && !bannerDismissed ? "calc(100vh - 32px)" : "100vh" }}>
657
736
  {/* Mobile sidebar backdrop */}
658
737
  {mobile && sbOpen && (
659
738
  <div onClick={() => setSb(false)} style={{
@@ -662,12 +741,25 @@ export function Shell({
662
741
  }} />
663
742
  )}
664
743
  {/* Sidebar */}
744
+ {overrides?.Sidebar ? (
745
+ <overrides.Sidebar
746
+ config={config}
747
+ navigation={navigation}
748
+ currentPageId={currentPageId}
749
+ onNavigate={onNavigate}
750
+ mobile={mobile}
751
+ sbOpen={sbOpen}
752
+ setSbOpen={setSb}
753
+ versioning={versioning}
754
+ currentVersion={currentVersion}
755
+ />
756
+ ) : (
665
757
  <aside style={{
666
758
  width: sbOpen ? 270 : 0, minWidth: sbOpen ? 270 : 0,
667
- background: "var(--sbBg)", borderRight: "1px solid var(--bd)",
759
+ background: "var(--sbBg)", [isRtl ? "borderLeft" : "borderRight"]: "1px solid var(--bd)",
668
760
  display: "flex", flexDirection: "column",
669
761
  transition: "width .2s, min-width .2s", overflow: "hidden",
670
- ...(mobile ? { position: "fixed" as const, top: 0, left: 0, bottom: 0, zIndex: 201 } : {}),
762
+ ...(mobile ? { position: "fixed" as const, top: 0, [isRtl ? "right" : "left"]: 0, bottom: 0, zIndex: 201 } : {}),
671
763
  }}>
672
764
  <a href="/" style={{ padding: "18px 20px", display: "flex", alignItems: "baseline", gap: 6, borderBottom: "1px solid var(--bd)", textDecoration: "none", color: "inherit" }}>
673
765
  <span style={{ fontFamily: "var(--font-heading)", fontSize: 22, fontWeight: 700, fontStyle: "italic" }}>
@@ -683,7 +775,7 @@ export function Shell({
683
775
  padding: "8px 12px", cursor: "pointer", color: "var(--txM)", fontSize: 12.5,
684
776
  fontFamily: "var(--font-body)",
685
777
  }}>
686
- <SearchIcon /><span style={{ flex: 1, textAlign: "left" }}>Search...</span>
778
+ <SearchIcon /><span style={{ flex: 1, textAlign: isRtl ? "right" : "left" }}>Search...</span>
687
779
  <kbd style={{ fontFamily: "var(--font-code)", fontSize: 9, background: "var(--sf)", border: "1px solid var(--bd)", borderRadius: 2, padding: "2px 6px" }}>{"\u2318K"}</kbd>
688
780
  </button>
689
781
  </div>
@@ -699,21 +791,38 @@ export function Shell({
699
791
  }}>
700
792
  {expanded.includes(sec.section) ? <ChevDown /> : <ChevRight />}{sec.section}
701
793
  </button>
702
- {expanded.includes(sec.section) && <div style={{ marginLeft: 8, borderLeft: "1px solid var(--bd)", paddingLeft: 0 }}>
794
+ {expanded.includes(sec.section) && <div style={{ [isRtl ? "marginRight" : "marginLeft"]: 8, [isRtl ? "borderRight" : "borderLeft"]: "1px solid var(--bd)", [isRtl ? "paddingRight" : "paddingLeft"]: 0 }}>
703
795
  {sec.pages.map(p => {
704
796
  const active = currentPageId === p.id;
705
797
  return (
706
798
  <button key={p.id} onClick={() => { onNavigate(p.id); if (mobile) setSb(false); }} style={{
707
799
  display: "flex", alignItems: "center", gap: 10, width: "100%",
708
- textAlign: "left", background: "none",
800
+ textAlign: isRtl ? "right" : "left", background: "none",
709
801
  border: "none", borderRadius: 0,
710
- borderLeft: active ? "2px solid var(--ac)" : "2px solid transparent",
802
+ [isRtl ? "borderRight" : "borderLeft"]: active ? "2px solid var(--ac)" : "2px solid transparent",
711
803
  padding: "7px 14px", cursor: "pointer",
712
804
  color: active ? "var(--ac)" : "var(--tx2)", fontSize: 13,
713
805
  fontWeight: active ? 500 : 400, fontFamily: "var(--font-body)",
714
806
  transition: "all .12s",
715
807
  }}>
716
808
  {p.title}
809
+ {p.badge && (() => {
810
+ const badgeColors: Record<string, { bg: string; text: string }> = {
811
+ default: { bg: "var(--sf)", text: "var(--tx2)" },
812
+ info: { bg: "rgba(59,130,246,0.15)", text: "rgb(59,130,246)" },
813
+ success: { bg: "rgba(34,197,94,0.15)", text: "rgb(34,197,94)" },
814
+ warning: { bg: "rgba(234,179,8,0.15)", text: "rgb(202,138,4)" },
815
+ danger: { bg: "rgba(239,68,68,0.15)", text: "rgb(239,68,68)" },
816
+ };
817
+ const bc = badgeColors[p.badge!.variant || "default"] || badgeColors.default;
818
+ return (
819
+ <span style={{
820
+ fontSize: 10, fontWeight: 600, padding: "2px 6px",
821
+ borderRadius: 4, marginLeft: 6, whiteSpace: "nowrap",
822
+ background: bc.bg, color: bc.text,
823
+ }}>{p.badge!.text}</span>
824
+ );
825
+ })()}
717
826
  </button>
718
827
  );
719
828
  })}
@@ -734,11 +843,11 @@ export function Shell({
734
843
  onNavigate(targetId);
735
844
  }}
736
845
  style={{
737
- flex: 1, padding: "6px 0", textAlign: "center",
846
+ flex: 1, padding: "3px 0", textAlign: "center",
738
847
  background: v === (currentVersion || versioning.current) ? "var(--acD)" : "var(--sf)",
739
848
  border: "1px solid var(--bd)", borderRadius: 2, cursor: "pointer",
740
849
  color: v === (currentVersion || versioning.current) ? "var(--ac)" : "var(--tx2)",
741
- fontSize: 12, fontFamily: "var(--font-code)",
850
+ fontSize: 11, fontFamily: "var(--font-code)",
742
851
  fontWeight: v === versioning.current ? 600 : 400,
743
852
  }}
744
853
  >
@@ -758,10 +867,30 @@ export function Shell({
758
867
  <span style={{ fontFamily: "var(--font-code)", fontSize: 10, color: "var(--txM)" }}>{typeof __TOME_VERSION__ !== "undefined" && __TOME_VERSION__ ? `v${__TOME_VERSION__}` : "v0.1.0"}</span>
759
868
  </div>
760
869
  </aside>
870
+ )}
761
871
 
762
872
  {/* Main area */}
763
873
  <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
764
874
  {/* Header */}
875
+ {overrides?.Header ? (
876
+ <overrides.Header
877
+ config={config}
878
+ navigation={navigation}
879
+ currentPageId={currentPageId}
880
+ onNavigate={onNavigate}
881
+ mobile={mobile}
882
+ sbOpen={sbOpen}
883
+ setSbOpen={setSb}
884
+ isDark={isDark}
885
+ setDark={setDark}
886
+ versioning={versioning}
887
+ currentVersion={currentVersion}
888
+ i18n={i18n}
889
+ currentLocale={currentLocale}
890
+ onSearchOpen={() => setSearch(true)}
891
+ basePath={basePath}
892
+ />
893
+ ) : (
765
894
  <header style={{
766
895
  display: "flex", alignItems: "center", gap: mobile ? 8 : 12, padding: mobile ? "8px 12px" : "10px 24px",
767
896
  borderBottom: "1px solid var(--bd)", background: "var(--hdBg)", backdropFilter: "blur(12px)",
@@ -820,6 +949,30 @@ export function Shell({
820
949
  </div>
821
950
  )}
822
951
 
952
+ {/* Social Links */}
953
+ {config.socialLinks && config.socialLinks.length > 0 && !mobile && (
954
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
955
+ {config.socialLinks.map((link) => (
956
+ <a
957
+ key={link.url}
958
+ href={link.url}
959
+ target="_blank"
960
+ rel="noopener noreferrer"
961
+ aria-label={link.label || link.platform}
962
+ data-testid={`social-link-${link.platform}`}
963
+ style={{
964
+ display: "flex", alignItems: "center", justifyContent: "center",
965
+ color: "var(--tx2)", cursor: "pointer", transition: "color .15s",
966
+ }}
967
+ onMouseOver={(e) => (e.currentTarget.style.color = "var(--tx)")}
968
+ onMouseOut={(e) => (e.currentTarget.style.color = "var(--tx2)")}
969
+ >
970
+ <SocialIcon platform={link.platform} customIcon={link.icon} />
971
+ </a>
972
+ ))}
973
+ </div>
974
+ )}
975
+
823
976
  {/* Theme toggle in header on mobile */}
824
977
  {mobile && themeMode === "auto" && (
825
978
  <button aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"} onClick={() => setDark(d => !d)} style={{ background: "none", border: "none", color: "var(--txM)", cursor: "pointer", display: "flex", flexShrink: 0 }}>
@@ -947,6 +1100,7 @@ export function Shell({
947
1100
  </div>
948
1101
  )}
949
1102
  </header>
1103
+ )}
950
1104
 
951
1105
  {/* TOM-30: Old version banner */}
952
1106
  {isOldVersion && (
@@ -975,13 +1129,49 @@ export function Shell({
975
1129
  {/* Content + TOC */}
976
1130
  <div ref={contentRef} style={{ flex: 1, overflow: "auto", display: "flex" }}>
977
1131
  <main style={{ flex: 1, maxWidth: mobile ? "100%" : 760, padding: mobile ? "24px 16px 60px" : "40px 48px 80px", margin: "0 auto", minWidth: 0 }}>
1132
+ {breadcrumbs.length > 0 && (
1133
+ <nav aria-label="Breadcrumbs" data-testid="breadcrumbs" style={{
1134
+ display: "flex", alignItems: "center", gap: 6,
1135
+ fontSize: 13, color: "var(--tx2)", marginBottom: 8,
1136
+ }}>
1137
+ {breadcrumbs.map((crumb, i) => (
1138
+ <React.Fragment key={i}>
1139
+ {i > 0 && <span style={{ color: "var(--tx2)", opacity: 0.5 }}>{"\u203A"}</span>}
1140
+ {i < breadcrumbs.length - 1 && crumb.href !== null ? (
1141
+ <a
1142
+ href={crumb.href}
1143
+ onClick={(e: React.MouseEvent) => {
1144
+ e.preventDefault();
1145
+ // Find the page id for this href
1146
+ const page = navigation.flatMap(s => s.pages).find(p => p.urlPath === crumb.href);
1147
+ if (page) onNavigate(page.id);
1148
+ }}
1149
+ style={{ color: "var(--tx2)", textDecoration: "none", cursor: "pointer" }}
1150
+ >
1151
+ {crumb.label}
1152
+ </a>
1153
+ ) : (
1154
+ <span style={i === breadcrumbs.length - 1 ? { color: "var(--tx)" } : undefined}>{crumb.label}</span>
1155
+ )}
1156
+ </React.Fragment>
1157
+ ))}
1158
+ </nav>
1159
+ )}
978
1160
  <h1 style={{ fontFamily: "var(--font-heading)", fontSize: mobile ? 26 : 38, fontWeight: 400, fontStyle: "italic", lineHeight: 1.15, marginBottom: 8 }}>
979
1161
  {pageTitle}
980
1162
  </h1>
1163
+ {isDraft && (
1164
+ <div data-testid="draft-banner" style={{ background: "#fef3c7", color: "#92400e", padding: "8px 16px", borderRadius: 6, fontSize: 13, marginBottom: 16 }}>
1165
+ Draft — This page is only visible in development
1166
+ </div>
1167
+ )}
981
1168
  {pageDescription && <p style={{ fontSize: 16, color: "var(--tx2)", lineHeight: 1.6, marginBottom: 32 }}>{pageDescription}</p>}
982
1169
  <div style={{ borderTop: "1px solid var(--bd)", paddingTop: 28 }}>
983
- {/* TOM-49: Changelog page type */}
984
- {changelogEntries && changelogEntries.length > 0 ? (
1170
+ {/* TOM-19: API Reference page */}
1171
+ {apiManifest && ApiReferenceComponent ? (
1172
+ <ApiReferenceComponent manifest={apiManifest} baseUrl={apiBaseUrl} showPlayground={apiPlayground} playgroundAuth={apiAuth} />
1173
+ ) : /* TOM-49: Changelog page type */
1174
+ changelogEntries && changelogEntries.length > 0 ? (
985
1175
  <ChangelogView entries={changelogEntries} />
986
1176
  ) : PageComponent ? (
987
1177
  <div className="tome-content">
@@ -995,7 +1185,19 @@ export function Shell({
995
1185
  )}
996
1186
  </div>
997
1187
 
998
- {/* TOM-48: Edit this page link + TOM-54: Last updated */}
1188
+ {/* TOM-48: Edit this page link + TOM-54: Last updated + Feedback + Prev/Next */}
1189
+ {overrides?.PageFooter ? (
1190
+ <overrides.PageFooter
1191
+ editUrl={editUrl}
1192
+ lastUpdated={lastUpdated}
1193
+ currentPageId={currentPageId}
1194
+ prev={prev}
1195
+ next={next}
1196
+ onNavigate={onNavigate}
1197
+ mobile={mobile}
1198
+ />
1199
+ ) : (
1200
+ <>
999
1201
  {(editUrl || lastUpdated) && (
1000
1202
  <div style={{ marginTop: 40, display: "flex", flexDirection: mobile ? "column" : "row", alignItems: mobile ? "flex-start" : "center", justifyContent: "space-between", gap: mobile ? 8 : 16 }}>
1001
1203
  {editUrl && (
@@ -1049,7 +1251,7 @@ export function Shell({
1049
1251
  border: "1px solid var(--bd)", borderRadius: 2, padding: "10px 16px",
1050
1252
  cursor: "pointer", color: "var(--tx2)", fontSize: 13, fontFamily: "var(--font-body)",
1051
1253
  transition: "border-color .15s, color .15s",
1052
- }}><ArrowLeft /> {prev.title}</button>
1254
+ }}>{isRtl ? <ArrowRight /> : <ArrowLeft />} {prev.title}</button>
1053
1255
  ) : <div />}
1054
1256
  {next ? (
1055
1257
  <button onClick={() => onNavigate(next.id)} style={{
@@ -1057,16 +1259,27 @@ export function Shell({
1057
1259
  border: "1px solid var(--bd)", borderRadius: 2, padding: "10px 16px",
1058
1260
  cursor: "pointer", color: "var(--tx2)", fontSize: 13, fontFamily: "var(--font-body)",
1059
1261
  transition: "border-color .15s, color .15s",
1060
- }}>{next.title} <ArrowRight /></button>
1262
+ }}>{next.title} {isRtl ? <ArrowLeft /> : <ArrowRight />}</button>
1061
1263
  ) : <div />}
1062
1264
  </div>
1265
+ </>
1266
+ )}
1063
1267
  </main>
1064
1268
 
1065
1269
  {/* TOC (TOM-52) */}
1066
- {showToc && filteredHeadings.length >= 2 && wide && (
1067
- <aside data-testid="toc-sidebar" style={{ width: 200, padding: "40px 16px 40px 0", position: "sticky", top: 0, alignSelf: "flex-start", flexShrink: 0 }}>
1270
+ {overrides?.Toc ? (
1271
+ showToc && filteredHeadings.length >= 2 && wide && (
1272
+ <overrides.Toc
1273
+ headings={filteredHeadings}
1274
+ activeHeadingId={activeHeadingId}
1275
+ onScrollToHeading={scrollToHeading}
1276
+ />
1277
+ )
1278
+ ) : (
1279
+ showToc && filteredHeadings.length >= 2 && wide && (
1280
+ <aside data-testid="toc-sidebar" style={{ width: 200, padding: isRtl ? "40px 0 40px 16px" : "40px 16px 40px 0", position: "sticky", top: 0, alignSelf: "flex-start", flexShrink: 0 }}>
1068
1281
  <div style={{ fontSize: 10, fontWeight: 600, textTransform: "uppercase", letterSpacing: ".1em", color: "var(--txM)", marginBottom: 12, fontFamily: "var(--font-code)" }}>On this page</div>
1069
- <nav aria-label="Table of contents" style={{ borderLeft: "1px solid var(--bd)", paddingLeft: 0 }}>
1282
+ <nav aria-label="Table of contents" style={{ [isRtl ? "borderRight" : "borderLeft"]: "1px solid var(--bd)", [isRtl ? "paddingRight" : "paddingLeft"]: 0 }}>
1070
1283
  {filteredHeadings.map((h, i) => {
1071
1284
  const isActive = activeHeadingId === h.id;
1072
1285
  return (
@@ -1081,22 +1294,33 @@ export function Shell({
1081
1294
  fontWeight: isActive ? 500 : 400,
1082
1295
  textDecoration: "none",
1083
1296
  padding: "4px 12px",
1084
- paddingLeft: 12 + (h.depth - 2) * 12,
1297
+ [isRtl ? "paddingRight" : "paddingLeft"]: 12 + (h.depth - 2) * 12,
1085
1298
  lineHeight: 1.4,
1086
1299
  transition: "color .15s, font-weight .15s",
1087
- borderLeft: isActive ? "2px solid var(--ac)" : "2px solid transparent",
1088
- marginLeft: -1,
1300
+ [isRtl ? "borderRight" : "borderLeft"]: isActive ? "2px solid var(--ac)" : "2px solid transparent",
1301
+ [isRtl ? "marginRight" : "marginLeft"]: -1,
1089
1302
  }}
1090
1303
  >{h.text}</a>
1091
1304
  );
1092
1305
  })}
1093
1306
  </nav>
1094
1307
  </aside>
1308
+ )
1095
1309
  )}
1096
1310
  </div>
1097
1311
  </div>
1098
1312
  </div>
1099
1313
 
1314
+ {/* Footer override */}
1315
+ {overrides?.Footer && (
1316
+ <overrides.Footer
1317
+ config={config}
1318
+ navigation={navigation}
1319
+ currentPageId={currentPageId}
1320
+ onNavigate={onNavigate}
1321
+ />
1322
+ )}
1323
+
1100
1324
  {/* TOM-32: AI Chat Widget (BYOK) */}
1101
1325
  {config.ai?.enabled && (
1102
1326
  <AiChat
@@ -0,0 +1,2 @@
1
+ // Stub for virtual:tome/config — overridden by vi.mock in tests
2
+ export default {};
@@ -0,0 +1,2 @@
1
+ // Stub for virtual:tome/doc-context — overridden by vi.mock in tests
2
+ export default "";
@@ -0,0 +1,2 @@
1
+ // Stub for virtual:tome/overrides — overridden by vi.mock in tests
2
+ export default null;
@@ -0,0 +1,4 @@
1
+ // Stub for virtual:tome/page-loader — overridden by vi.mock in tests
2
+ export default async function loadPageModule(_id: string): Promise<any> {
3
+ return { default: null };
4
+ }
@@ -0,0 +1,5 @@
1
+ // Stub for virtual:tome/routes — overridden by vi.mock in tests
2
+ export const routes: any[] = [];
3
+ export const navigation: any[] = [];
4
+ export const versions: any = null;
5
+ export const i18n: any = null;
@@ -271,6 +271,82 @@ describe("loadPage", () => {
271
271
  expect(page!.isMdx).toBe(false);
272
272
  });
273
273
 
274
+ it("loads an API reference page with manifest", async () => {
275
+ const apiManifest = {
276
+ title: "My API",
277
+ servers: [{ url: "https://api.example.com" }],
278
+ tags: [{ name: "Users", description: "User endpoints" }],
279
+ endpoints: [],
280
+ };
281
+ const mockLoader = vi.fn().mockResolvedValue({
282
+ default: {
283
+ html: "",
284
+ frontmatter: { title: "API Reference" },
285
+ headings: [{ depth: 2, text: "Users", id: "users" }],
286
+ },
287
+ isApiReference: true,
288
+ apiManifest,
289
+ });
290
+ const apiRoutes = [...routesWithMeta, { id: "api-reference", urlPath: "/api", isMdx: false }];
291
+ const page = await loadPage("api-reference", apiRoutes, mockLoader);
292
+ expect(page).not.toBeNull();
293
+ expect(page!.isMdx).toBe(false);
294
+ expect((page as any).isApiReference).toBe(true);
295
+ expect((page as any).apiManifest).toEqual(apiManifest);
296
+ expect(page!.frontmatter.title).toBe("API Reference");
297
+ });
298
+
299
+ it("does not treat page as API reference when isApiReference is false", async () => {
300
+ const mockLoader = vi.fn().mockResolvedValue({
301
+ default: {
302
+ html: "<p>Regular</p>",
303
+ frontmatter: { title: "Regular Page" },
304
+ headings: [],
305
+ },
306
+ isApiReference: false,
307
+ });
308
+ const page = await loadPage("quickstart", routesWithMeta, mockLoader);
309
+ expect(page).not.toBeNull();
310
+ expect(page!.isMdx).toBe(false);
311
+ expect((page as any).isApiReference).toBeFalsy();
312
+ expect((page as any).apiManifest).toBeUndefined();
313
+ });
314
+
315
+ it("does not treat page as API reference when apiManifest is missing", async () => {
316
+ const mockLoader = vi.fn().mockResolvedValue({
317
+ default: {
318
+ html: "",
319
+ frontmatter: { title: "API Reference" },
320
+ headings: [],
321
+ },
322
+ isApiReference: true,
323
+ // apiManifest is undefined
324
+ });
325
+ const page = await loadPage("api-reference", routesWithMeta, mockLoader);
326
+ expect(page).not.toBeNull();
327
+ // Without apiManifest, falls through to regular page handling
328
+ expect((page as any).isApiReference).toBeFalsy();
329
+ });
330
+
331
+ it("API reference page preserves headings from module", async () => {
332
+ const headings = [
333
+ { depth: 2, text: "Users", id: "users" },
334
+ { depth: 2, text: "Posts", id: "posts" },
335
+ ];
336
+ const mockLoader = vi.fn().mockResolvedValue({
337
+ default: {
338
+ html: "",
339
+ frontmatter: { title: "API Reference" },
340
+ headings,
341
+ },
342
+ isApiReference: true,
343
+ apiManifest: { endpoints: [], tags: [], servers: [] },
344
+ });
345
+ const apiRoutes = [...routesWithMeta, { id: "api-reference", urlPath: "/api", isMdx: false }];
346
+ const page = await loadPage("api-reference", apiRoutes, mockLoader);
347
+ expect(page!.headings).toEqual(headings);
348
+ });
349
+
274
350
  it("does not treat non-MDX route as MDX even if mod.meta exists", async () => {
275
351
  // A markdown route shouldn't be treated as MDX even if the module has meta
276
352
  const mockLoader = vi.fn().mockResolvedValue({
@@ -7,6 +7,7 @@ export interface HtmlPage {
7
7
  frontmatter: { title: string; description?: string; toc?: boolean; type?: string };
8
8
  headings: Array<{ depth: number; text: string; id: string }>;
9
9
  changelogEntries?: Array<{ version: string; date?: string; url?: string; sections: Array<{ type: string; items: string[] }> }>;
10
+ isApiReference?: false;
10
11
  }
11
12
 
12
13
  export interface MdxPage {
@@ -14,9 +15,20 @@ export interface MdxPage {
14
15
  component: React.ComponentType<{ components?: Record<string, React.ComponentType> }>;
15
16
  frontmatter: { title: string; description?: string; toc?: boolean; type?: string };
16
17
  headings: Array<{ depth: number; text: string; id: string }>;
18
+ isApiReference?: false;
17
19
  }
18
20
 
19
- export type LoadedPage = HtmlPage | MdxPage;
21
+ export interface ApiReferencePage {
22
+ isMdx: false;
23
+ isApiReference: true;
24
+ html: string;
25
+ frontmatter: { title: string; description?: string; toc?: boolean; type?: string };
26
+ headings: Array<{ depth: number; text: string; id: string }>;
27
+ changelogEntries?: undefined;
28
+ apiManifest: any;
29
+ }
30
+
31
+ export type LoadedPage = HtmlPage | MdxPage | ApiReferencePage;
20
32
 
21
33
  // ── EDIT URL COMPUTATION ──────────────────────────────────
22
34
  export interface EditLinkConfig {
@@ -82,6 +94,11 @@ export async function loadPage(
82
94
  // Regular .md page — mod.default is { html, frontmatter, headings }
83
95
  if (!mod.default) return null;
84
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
+
85
102
  // Changelog page type
86
103
  if (mod.isChangelog && mod.changelogEntries) {
87
104
  return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };