@tomehq/theme 0.3.2 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @tomehq/theme
2
2
 
3
+ ## 0.3.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 062349c: Fix MCP server stdout corruption, add dashboard mobile responsiveness, improve test coverage, and update docs.
8
+
9
+ - Fix: MCP CLI banner no longer writes to stdout, preventing JSON-RPC protocol corruption
10
+ - Fix: API Playground prop wiring and githubSource route crash
11
+ - Feat: MCP server `createMcpServer()` exported for programmatic use with graceful shutdown
12
+ - Feat: Dashboard mobile-responsive layout with media query breakpoints
13
+ - Feat: 13 MCP server integration tests using InMemoryTransport
14
+ - Docs: Add missing typedoc CLI command, fix social link examples, update package list
15
+
16
+ - Updated dependencies [062349c]
17
+ - @tomehq/core@0.3.3
18
+ - @tomehq/components@0.3.3
19
+
3
20
  ## 0.2.8
4
21
 
5
22
  ### Minor Changes
@@ -818,6 +818,11 @@ function Shell({
818
818
  editUrl,
819
819
  lastUpdated,
820
820
  changelogEntries,
821
+ apiManifest,
822
+ apiBaseUrl,
823
+ apiPlayground,
824
+ apiAuth,
825
+ ApiReferenceComponent,
821
826
  onNavigate,
822
827
  allPages,
823
828
  versioning,
@@ -1613,12 +1618,15 @@ function Shell({
1613
1618
  /* @__PURE__ */ jsx2("h1", { style: { fontFamily: "var(--font-heading)", fontSize: mobile ? 26 : 38, fontWeight: 400, fontStyle: "italic", lineHeight: 1.15, marginBottom: 8 }, children: pageTitle }),
1614
1619
  isDraft && /* @__PURE__ */ jsx2("div", { "data-testid": "draft-banner", style: { background: "#fef3c7", color: "#92400e", padding: "8px 16px", borderRadius: 6, fontSize: 13, marginBottom: 16 }, children: "Draft \u2014 This page is only visible in development" }),
1615
1620
  pageDescription && /* @__PURE__ */ jsx2("p", { style: { fontSize: 16, color: "var(--tx2)", lineHeight: 1.6, marginBottom: 32 }, children: pageDescription }),
1616
- /* @__PURE__ */ jsx2("div", { style: { borderTop: "1px solid var(--bd)", paddingTop: 28 }, children: changelogEntries && changelogEntries.length > 0 ? /* @__PURE__ */ jsx2(ChangelogView, { entries: changelogEntries }) : PageComponent ? /* @__PURE__ */ jsx2("div", { className: "tome-content", children: /* @__PURE__ */ jsx2(PageComponent, { components: mdxComponents || {} }) }) : /* @__PURE__ */ jsx2(
1617
- "div",
1618
- {
1619
- className: "tome-content",
1620
- ref: htmlContentRef
1621
- }
1621
+ /* @__PURE__ */ jsx2("div", { style: { borderTop: "1px solid var(--bd)", paddingTop: 28 }, children: apiManifest && ApiReferenceComponent ? /* @__PURE__ */ jsx2(ApiReferenceComponent, { manifest: apiManifest, baseUrl: apiBaseUrl, showPlayground: apiPlayground, playgroundAuth: apiAuth }) : (
1622
+ /* TOM-49: Changelog page type */
1623
+ changelogEntries && changelogEntries.length > 0 ? /* @__PURE__ */ jsx2(ChangelogView, { entries: changelogEntries }) : PageComponent ? /* @__PURE__ */ jsx2("div", { className: "tome-content", children: /* @__PURE__ */ jsx2(PageComponent, { components: mdxComponents || {} }) }) : /* @__PURE__ */ jsx2(
1624
+ "div",
1625
+ {
1626
+ className: "tome-content",
1627
+ ref: htmlContentRef
1628
+ }
1629
+ )
1622
1630
  ) }),
1623
1631
  overrides2?.PageFooter ? /* @__PURE__ */ jsx2(
1624
1632
  overrides2.PageFooter,
@@ -1986,6 +1994,9 @@ async function loadPage(id, routes2, loadPageModule2) {
1986
1994
  };
1987
1995
  }
1988
1996
  if (!mod.default) return null;
1997
+ if (mod.isApiReference && mod.apiManifest) {
1998
+ return { isMdx: false, isApiReference: true, ...mod.default, apiManifest: mod.apiManifest };
1999
+ }
1989
2000
  if (mod.isChangelog && mod.changelogEntries) {
1990
2001
  return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
1991
2002
  }
@@ -2018,7 +2029,8 @@ import {
2018
2029
  FileTree,
2019
2030
  CodeSamples,
2020
2031
  LinkCard,
2021
- CardGrid
2032
+ CardGrid,
2033
+ ApiReference
2022
2034
  } from "@tomehq/components";
2023
2035
  import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
2024
2036
  var MDX_COMPONENTS = {
@@ -2495,6 +2507,11 @@ function App() {
2495
2507
  editUrl,
2496
2508
  lastUpdated: currentRoute?.lastUpdated,
2497
2509
  changelogEntries: !pageData?.isMdx ? pageData?.changelogEntries : void 0,
2510
+ apiManifest: !pageData?.isMdx && pageData?.isApiReference ? pageData.apiManifest : void 0,
2511
+ apiBaseUrl: config.api?.baseUrl,
2512
+ apiPlayground: config.api?.playground,
2513
+ apiAuth: config.api?.auth,
2514
+ ApiReferenceComponent: ApiReference,
2498
2515
  onNavigate: navigateTo,
2499
2516
  allPages,
2500
2517
  docContext,
package/dist/entry.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  entry_default
3
- } from "./chunk-2AXAEADQ.js";
3
+ } from "./chunk-MSXVVBDW.js";
4
4
  export {
5
5
  entry_default as default
6
6
  };
package/dist/index.d.ts CHANGED
@@ -96,6 +96,22 @@ interface ShellProps {
96
96
  items: string[];
97
97
  }>;
98
98
  }>;
99
+ apiManifest?: any;
100
+ apiBaseUrl?: string;
101
+ apiPlayground?: boolean;
102
+ apiAuth?: {
103
+ type: "bearer" | "apiKey";
104
+ header?: string;
105
+ };
106
+ ApiReferenceComponent?: React.ComponentType<{
107
+ manifest: any;
108
+ baseUrl?: string;
109
+ showPlayground?: boolean;
110
+ playgroundAuth?: {
111
+ type: "bearer" | "apiKey";
112
+ header?: string;
113
+ };
114
+ }>;
99
115
  onNavigate: (id: string) => void;
100
116
  allPages: Array<{
101
117
  id: string;
@@ -122,7 +138,7 @@ interface ShellProps {
122
138
  PageFooter?: React.ComponentType<any>;
123
139
  };
124
140
  }
125
- declare function Shell({ config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents, pageTitle, pageDescription, headings, tocEnabled, editUrl, lastUpdated, changelogEntries, onNavigate, allPages, versioning, currentVersion, i18n, currentLocale, docContext, basePath, isDraft, dir: dirProp, overrides, }: ShellProps): react_jsx_runtime.JSX.Element;
141
+ declare function Shell({ config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents, pageTitle, pageDescription, headings, tocEnabled, editUrl, lastUpdated, changelogEntries, apiManifest, apiBaseUrl, apiPlayground, apiAuth, ApiReferenceComponent, onNavigate, allPages, versioning, currentVersion, i18n, currentLocale, docContext, basePath, isDraft, dir: dirProp, overrides, }: ShellProps): react_jsx_runtime.JSX.Element;
126
142
 
127
143
  interface AiChatProps {
128
144
  provider: "openai" | "anthropic" | "custom";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  Shell,
4
4
  THEME_PRESETS,
5
5
  entry_default
6
- } from "./chunk-2AXAEADQ.js";
6
+ } from "./chunk-MSXVVBDW.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.3.2",
3
+ "version": "0.3.3",
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.3.2",
13
- "@tomehq/core": "0.3.2"
12
+ "@tomehq/components": "0.3.3",
13
+ "@tomehq/core": "0.3.3"
14
14
  },
15
15
  "peerDependencies": {
16
16
  "react": "^18.0.0 || ^19.0.0",
@@ -1052,3 +1052,225 @@ describe("Shell RTL support", () => {
1052
1052
  ).not.toThrow();
1053
1053
  });
1054
1054
  });
1055
+
1056
+ // ── API Reference (TOM-19) ──────────────────────────────
1057
+
1058
+ describe("Shell API reference rendering", () => {
1059
+ const mockManifest = {
1060
+ title: "Test API",
1061
+ version: "1.0.0",
1062
+ servers: [{ url: "https://api.example.com" }],
1063
+ tags: [{ name: "Users", description: "User management" }],
1064
+ endpoints: [
1065
+ {
1066
+ method: "GET",
1067
+ path: "/users",
1068
+ summary: "List all users",
1069
+ tags: ["Users"],
1070
+ parameters: [],
1071
+ responses: [{ status: "200", description: "OK" }],
1072
+ },
1073
+ ],
1074
+ };
1075
+
1076
+ function MockApiRef({ manifest, baseUrl, showPlayground, playgroundAuth }: { manifest: any; baseUrl?: string; showPlayground?: boolean; playgroundAuth?: { type: string; header?: string } }) {
1077
+ return (
1078
+ <div data-testid="api-reference">
1079
+ <span data-testid="api-title">{manifest.title}</span>
1080
+ {baseUrl && <span data-testid="api-base-url">{baseUrl}</span>}
1081
+ {showPlayground && <span data-testid="api-playground">playground-enabled</span>}
1082
+ {playgroundAuth && <span data-testid="api-auth">{playgroundAuth.type}</span>}
1083
+ </div>
1084
+ );
1085
+ }
1086
+
1087
+ it("renders ApiReferenceComponent when both apiManifest and component are provided", () => {
1088
+ renderShell({
1089
+ apiManifest: mockManifest,
1090
+ ApiReferenceComponent: MockApiRef,
1091
+ pageHtml: undefined,
1092
+ });
1093
+ expect(screen.getByTestId("api-reference")).toBeInTheDocument();
1094
+ expect(screen.getByTestId("api-title")).toHaveTextContent("Test API");
1095
+ });
1096
+
1097
+ it("passes baseUrl to ApiReferenceComponent", () => {
1098
+ renderShell({
1099
+ apiManifest: mockManifest,
1100
+ ApiReferenceComponent: MockApiRef,
1101
+ apiBaseUrl: "https://api.example.com",
1102
+ pageHtml: undefined,
1103
+ });
1104
+ expect(screen.getByTestId("api-base-url")).toHaveTextContent("https://api.example.com");
1105
+ });
1106
+
1107
+ it("does not render API reference when only apiManifest is provided (no component)", () => {
1108
+ renderShell({
1109
+ apiManifest: mockManifest,
1110
+ pageHtml: "<p>Fallback content</p>",
1111
+ });
1112
+ expect(screen.queryByTestId("api-reference")).not.toBeInTheDocument();
1113
+ });
1114
+
1115
+ it("does not render API reference when only component is provided (no manifest)", () => {
1116
+ renderShell({
1117
+ ApiReferenceComponent: MockApiRef,
1118
+ pageHtml: "<p>Fallback content</p>",
1119
+ });
1120
+ expect(screen.queryByTestId("api-reference")).not.toBeInTheDocument();
1121
+ });
1122
+
1123
+ it("falls back to pageHtml when no apiManifest", () => {
1124
+ const { container } = renderShell({
1125
+ pageHtml: "<p>Regular page content</p>",
1126
+ });
1127
+ expect(container.querySelector(".tome-content")).toBeInTheDocument();
1128
+ expect(screen.queryByTestId("api-reference")).not.toBeInTheDocument();
1129
+ });
1130
+
1131
+ it("API reference takes precedence over changelog entries", () => {
1132
+ const changelogEntries = [
1133
+ { version: "1.0.0", sections: [{ type: "Added", items: ["Feature"] }] },
1134
+ ];
1135
+ renderShell({
1136
+ apiManifest: mockManifest,
1137
+ ApiReferenceComponent: MockApiRef,
1138
+ changelogEntries,
1139
+ pageHtml: undefined,
1140
+ });
1141
+ // API reference should render, not changelog
1142
+ expect(screen.getByTestId("api-reference")).toBeInTheDocument();
1143
+ expect(screen.queryByTestId("changelog-timeline")).not.toBeInTheDocument();
1144
+ });
1145
+
1146
+ it("API reference takes precedence over pageComponent", () => {
1147
+ const PageComp = () => <div data-testid="page-comp">MDX content</div>;
1148
+ renderShell({
1149
+ apiManifest: mockManifest,
1150
+ ApiReferenceComponent: MockApiRef,
1151
+ pageComponent: PageComp,
1152
+ pageHtml: undefined,
1153
+ });
1154
+ expect(screen.getByTestId("api-reference")).toBeInTheDocument();
1155
+ expect(screen.queryByTestId("page-comp")).not.toBeInTheDocument();
1156
+ });
1157
+
1158
+ it("passes showPlayground prop to ApiReferenceComponent", () => {
1159
+ renderShell({
1160
+ apiManifest: mockManifest,
1161
+ ApiReferenceComponent: MockApiRef,
1162
+ apiPlayground: true,
1163
+ pageHtml: undefined,
1164
+ });
1165
+ expect(screen.getByTestId("api-playground")).toHaveTextContent("playground-enabled");
1166
+ });
1167
+
1168
+ it("passes playgroundAuth prop to ApiReferenceComponent", () => {
1169
+ renderShell({
1170
+ apiManifest: mockManifest,
1171
+ ApiReferenceComponent: MockApiRef,
1172
+ apiPlayground: true,
1173
+ apiAuth: { type: "bearer" },
1174
+ pageHtml: undefined,
1175
+ });
1176
+ expect(screen.getByTestId("api-auth")).toHaveTextContent("bearer");
1177
+ });
1178
+
1179
+ it("does not pass playground props when not provided", () => {
1180
+ renderShell({
1181
+ apiManifest: mockManifest,
1182
+ ApiReferenceComponent: MockApiRef,
1183
+ pageHtml: undefined,
1184
+ });
1185
+ expect(screen.getByTestId("api-reference")).toBeInTheDocument();
1186
+ expect(screen.queryByTestId("api-playground")).not.toBeInTheDocument();
1187
+ expect(screen.queryByTestId("api-auth")).not.toBeInTheDocument();
1188
+ });
1189
+ });
1190
+
1191
+ // ── Content link interception ───────────────────────────────
1192
+
1193
+ describe("Content link interception", () => {
1194
+ it("intercepts internal bare page ID links and calls onNavigate", () => {
1195
+ const onNavigate = vi.fn();
1196
+ renderShell({
1197
+ onNavigate,
1198
+ pageHtml: '<p><a href="quickstart">Go to quickstart</a></p>',
1199
+ });
1200
+ const link = screen.getByText("Go to quickstart");
1201
+ fireEvent.click(link);
1202
+ expect(onNavigate).toHaveBeenCalledWith("quickstart");
1203
+ });
1204
+
1205
+ it("intercepts ./relative links and strips the prefix", () => {
1206
+ const onNavigate = vi.fn();
1207
+ renderShell({
1208
+ onNavigate,
1209
+ pageHtml: '<p><a href="./installation">Install</a></p>',
1210
+ });
1211
+ fireEvent.click(screen.getByText("Install"));
1212
+ expect(onNavigate).toHaveBeenCalledWith("installation");
1213
+ });
1214
+
1215
+ it("does not intercept external http links", () => {
1216
+ const onNavigate = vi.fn();
1217
+ renderShell({
1218
+ onNavigate,
1219
+ pageHtml: '<p><a href="https://example.com">External</a></p>',
1220
+ });
1221
+ fireEvent.click(screen.getByText("External"));
1222
+ expect(onNavigate).not.toHaveBeenCalled();
1223
+ });
1224
+
1225
+ it("does not intercept mailto links", () => {
1226
+ const onNavigate = vi.fn();
1227
+ renderShell({
1228
+ onNavigate,
1229
+ pageHtml: '<p><a href="mailto:test@example.com">Email</a></p>',
1230
+ });
1231
+ fireEvent.click(screen.getByText("Email"));
1232
+ expect(onNavigate).not.toHaveBeenCalled();
1233
+ });
1234
+
1235
+ it("does not intercept pure anchor links", () => {
1236
+ const onNavigate = vi.fn();
1237
+ renderShell({
1238
+ onNavigate,
1239
+ pageHtml: '<p><a href="#section-1">Jump to section</a></p>',
1240
+ });
1241
+ fireEvent.click(screen.getByText("Jump to section"));
1242
+ expect(onNavigate).not.toHaveBeenCalled();
1243
+ });
1244
+
1245
+ it("strips basePath prefix from links", () => {
1246
+ const onNavigate = vi.fn();
1247
+ renderShell({
1248
+ onNavigate,
1249
+ basePath: "/docs/",
1250
+ pageHtml: '<p><a href="/docs/quickstart">Go</a></p>',
1251
+ });
1252
+ fireEvent.click(screen.getByText("Go"));
1253
+ expect(onNavigate).toHaveBeenCalledWith("quickstart");
1254
+ });
1255
+
1256
+ it("resolves basePath-only link to index", () => {
1257
+ const onNavigate = vi.fn();
1258
+ renderShell({
1259
+ onNavigate,
1260
+ basePath: "/docs/",
1261
+ pageHtml: '<p><a href="/docs/">Home</a></p>',
1262
+ });
1263
+ fireEvent.click(screen.getByText("Home"));
1264
+ expect(onNavigate).toHaveBeenCalledWith("index");
1265
+ });
1266
+
1267
+ it("resolves empty path to index", () => {
1268
+ const onNavigate = vi.fn();
1269
+ renderShell({
1270
+ onNavigate,
1271
+ pageHtml: '<p><a href="./">Home</a></p>',
1272
+ });
1273
+ fireEvent.click(screen.getByText("Home"));
1274
+ expect(onNavigate).toHaveBeenCalledWith("index");
1275
+ });
1276
+ });
package/src/Shell.tsx CHANGED
@@ -367,6 +367,16 @@ interface ShellProps {
367
367
  editUrl?: string;
368
368
  lastUpdated?: string;
369
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
+ }>;
370
380
  onNavigate: (id: string) => void;
371
381
  allPages: Array<{ id: string; title: string; description?: string }>;
372
382
  versioning?: VersioningInfo;
@@ -388,7 +398,8 @@ interface ShellProps {
388
398
 
389
399
  export function Shell({
390
400
  config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents,
391
- pageTitle, pageDescription, headings, tocEnabled = true, editUrl, lastUpdated, changelogEntries, onNavigate, allPages,
401
+ pageTitle, pageDescription, headings, tocEnabled = true, editUrl, lastUpdated, changelogEntries,
402
+ apiManifest, apiBaseUrl, apiPlayground, apiAuth, ApiReferenceComponent, onNavigate, allPages,
392
403
  versioning, currentVersion, i18n, currentLocale, docContext, basePath = "", isDraft, dir: dirProp, overrides,
393
404
  }: ShellProps) {
394
405
  // RTL support: resolve text direction from prop, i18n.localeDirs, or default to "ltr"
@@ -1156,8 +1167,11 @@ export function Shell({
1156
1167
  )}
1157
1168
  {pageDescription && <p style={{ fontSize: 16, color: "var(--tx2)", lineHeight: 1.6, marginBottom: 32 }}>{pageDescription}</p>}
1158
1169
  <div style={{ borderTop: "1px solid var(--bd)", paddingTop: 28 }}>
1159
- {/* TOM-49: Changelog page type */}
1160
- {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 ? (
1161
1175
  <ChangelogView entries={changelogEntries} />
1162
1176
  ) : PageComponent ? (
1163
1177
  <div className="tome-content">
@@ -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 };
@@ -0,0 +1,695 @@
1
+ import React from "react";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { render, act } from "@testing-library/react";
4
+
5
+ // ── Capture Shell props ──────────────────────────────────
6
+ let capturedShellProps: any = null;
7
+
8
+ vi.mock("./Shell.js", () => ({
9
+ Shell: (props: any) => {
10
+ capturedShellProps = props;
11
+ return <div data-testid="shell" />;
12
+ },
13
+ }));
14
+
15
+ // ── Mock virtual modules (using resolved stub paths) ─────
16
+ // Vite resolve.alias maps virtual:tome/* to __virtual_stubs/*,
17
+ // so vi.mock must target the resolved path.
18
+
19
+ vi.mock("virtual:tome/config", () => ({
20
+ default: {
21
+ name: "Test Docs",
22
+ theme: { preset: "amber", mode: "light" },
23
+ basePath: "/docs/",
24
+ editLink: { repo: "test/repo", branch: "main", dir: "docs" },
25
+ api: {
26
+ spec: "./openapi.json",
27
+ baseUrl: "https://api.example.com",
28
+ playground: true,
29
+ auth: { type: "bearer", header: "Authorization" },
30
+ },
31
+ },
32
+ }));
33
+
34
+ const mockRoutes = [
35
+ {
36
+ id: "index",
37
+ urlPath: "/",
38
+ filePath: "pages/index.md",
39
+ frontmatter: { title: "Home", description: "Welcome" },
40
+ },
41
+ {
42
+ id: "quickstart",
43
+ urlPath: "/quickstart",
44
+ filePath: "pages/quickstart.md",
45
+ frontmatter: { title: "Quick Start", description: "Get started" },
46
+ },
47
+ {
48
+ id: "api-reference",
49
+ urlPath: "/api",
50
+ filePath: "__api-reference__",
51
+ frontmatter: { title: "API Reference", description: "API docs" },
52
+ },
53
+ ];
54
+
55
+ const mockNavigation = [
56
+ { section: "Guide", pages: [{ id: "index", title: "Home", urlPath: "/" }] },
57
+ ];
58
+
59
+ vi.mock("virtual:tome/routes", () => ({
60
+ routes: [
61
+ {
62
+ id: "index",
63
+ urlPath: "/",
64
+ filePath: "pages/index.md",
65
+ frontmatter: { title: "Home", description: "Welcome" },
66
+ },
67
+ {
68
+ id: "quickstart",
69
+ urlPath: "/quickstart",
70
+ filePath: "pages/quickstart.md",
71
+ frontmatter: { title: "Quick Start", description: "Get started" },
72
+ },
73
+ {
74
+ id: "api-reference",
75
+ urlPath: "/api",
76
+ filePath: "__api-reference__",
77
+ frontmatter: { title: "API Reference", description: "API docs" },
78
+ },
79
+ ],
80
+ navigation: [
81
+ { section: "Guide", pages: [{ id: "index", title: "Home", urlPath: "/" }] },
82
+ ],
83
+ versions: null,
84
+ i18n: null,
85
+ }));
86
+
87
+ vi.mock("virtual:tome/page-loader", () => ({
88
+ default: vi.fn().mockResolvedValue({
89
+ default: {
90
+ html: "<p>Hello</p>",
91
+ frontmatter: { title: "Home", description: "Welcome" },
92
+ headings: [],
93
+ },
94
+ }),
95
+ }));
96
+
97
+ vi.mock("virtual:tome/doc-context", () => ({
98
+ default: "Test doc context",
99
+ }));
100
+
101
+ vi.mock("virtual:tome/overrides", () => ({
102
+ default: { Footer: () => <div>Custom Footer</div> },
103
+ }));
104
+
105
+ // ── Mock entry-helpers ───────────────────────────────────
106
+ const mockLoadPage = vi.fn().mockResolvedValue({
107
+ isMdx: false,
108
+ isApiReference: false,
109
+ html: "<p>Test page</p>",
110
+ frontmatter: { title: "Home", description: "Welcome" },
111
+ headings: [{ id: "intro", text: "Intro", depth: 2 }],
112
+ });
113
+
114
+ const mockComputeEditUrl = vi.fn().mockReturnValue("https://github.com/test/repo/edit/main/docs/pages/index.md");
115
+ const mockResolveInitialPageId = vi.fn().mockReturnValue("index");
116
+ const mockDetectCurrentVersion = vi.fn().mockReturnValue(undefined);
117
+
118
+ vi.mock("./entry-helpers.js", () => ({
119
+ loadPage: (...args: any[]) => mockLoadPage(...args),
120
+ computeEditUrl: (...args: any[]) => mockComputeEditUrl(...args),
121
+ resolveInitialPageId: (...args: any[]) => mockResolveInitialPageId(...args),
122
+ detectCurrentVersion: (...args: any[]) => mockDetectCurrentVersion(...args),
123
+ }));
124
+
125
+ vi.mock("./routing.js", () => ({
126
+ pathnameToPageId: vi.fn().mockReturnValue("index"),
127
+ pageIdToPath: vi.fn().mockReturnValue("/docs/"),
128
+ }));
129
+
130
+ // ── Mock @tomehq/components ──────────────────────────────
131
+ const MockApiReference = (p: any) => <div data-testid="api-ref">{JSON.stringify(p)}</div>;
132
+
133
+ vi.mock("@tomehq/components", () => ({
134
+ Callout: (p: any) => <div>{p.children}</div>,
135
+ Tabs: (p: any) => <div>{p.children}</div>,
136
+ Card: (p: any) => <div>{p.children}</div>,
137
+ CardGroup: (p: any) => <div>{p.children}</div>,
138
+ Steps: (p: any) => <div>{p.children}</div>,
139
+ Accordion: (p: any) => <div>{p.children}</div>,
140
+ ChangelogTimeline: (p: any) => <div>{p.children}</div>,
141
+ PackageManager: () => <div />,
142
+ TypeTable: () => <div />,
143
+ FileTree: Object.assign(() => <div />, { File: () => <div />, Folder: () => <div /> }),
144
+ CodeSamples: () => <div />,
145
+ LinkCard: () => <div />,
146
+ CardGrid: () => <div />,
147
+ ApiReference: MockApiReference,
148
+ }));
149
+
150
+ // ── Global stubs ─────────────────────────────────────────
151
+ beforeEach(() => {
152
+ capturedShellProps = null;
153
+
154
+ // entry.tsx auto-mounts into #tome-root on import
155
+ if (!document.getElementById("tome-root")) {
156
+ const root = document.createElement("div");
157
+ root.id = "tome-root";
158
+ document.body.appendChild(root);
159
+ }
160
+
161
+ // jsdom stubs
162
+ window.scrollTo = vi.fn() as any;
163
+ window.history.pushState = vi.fn();
164
+ window.history.replaceState = vi.fn();
165
+ });
166
+
167
+ afterEach(() => {
168
+ capturedShellProps = null;
169
+ });
170
+
171
+ // ── Helper: render App and wait for async effects ────────
172
+ async function renderApp() {
173
+ // Dynamic import so mocks are in place first
174
+ const { default: App } = await import("./entry.js");
175
+ let result: ReturnType<typeof render>;
176
+ await act(async () => {
177
+ result = render(<App />);
178
+ });
179
+ // Wait for the initial page promise to resolve
180
+ await act(async () => {
181
+ await new Promise((r) => setTimeout(r, 10));
182
+ });
183
+ return result!;
184
+ }
185
+
186
+ // ══════════════════════════════════════════════════════════
187
+ // TESTS
188
+ // ══════════════════════════════════════════════════════════
189
+
190
+ describe("entry.tsx — Shell prop wiring", () => {
191
+ it("passes config object to Shell", async () => {
192
+ await renderApp();
193
+ expect(capturedShellProps).not.toBeNull();
194
+ expect(capturedShellProps.config).toBeDefined();
195
+ expect(capturedShellProps.config.name).toBe("Test Docs");
196
+ expect(capturedShellProps.config.theme).toEqual({ preset: "amber", mode: "light" });
197
+ });
198
+
199
+ it("passes navigation from virtual:tome/routes", async () => {
200
+ await renderApp();
201
+ expect(capturedShellProps.navigation).toEqual(mockNavigation);
202
+ });
203
+
204
+ it("passes currentPageId resolved from initial page", async () => {
205
+ await renderApp();
206
+ expect(capturedShellProps.currentPageId).toBe("index");
207
+ });
208
+
209
+ // ── API PROPS (the critical bug-catching tests) ────────
210
+
211
+ it("passes apiBaseUrl from config.api.baseUrl", async () => {
212
+ await renderApp();
213
+ expect(capturedShellProps.apiBaseUrl).toBe("https://api.example.com");
214
+ });
215
+
216
+ it("passes apiPlayground from config.api.playground", async () => {
217
+ await renderApp();
218
+ // THIS was the bug: playground was defined in config but never threaded to Shell
219
+ expect(capturedShellProps.apiPlayground).toBe(true);
220
+ });
221
+
222
+ it("passes apiAuth from config.api.auth", async () => {
223
+ await renderApp();
224
+ // THIS was the bug: auth was defined in config but never threaded to Shell
225
+ expect(capturedShellProps.apiAuth).toEqual({
226
+ type: "bearer",
227
+ header: "Authorization",
228
+ });
229
+ });
230
+
231
+ it("passes ApiReferenceComponent", async () => {
232
+ await renderApp();
233
+ expect(capturedShellProps.ApiReferenceComponent).toBeDefined();
234
+ expect(typeof capturedShellProps.ApiReferenceComponent).toBe("function");
235
+ });
236
+
237
+ // ── DOC CONTEXT & OVERRIDES ────────────────────────────
238
+
239
+ it("passes docContext from virtual:tome/doc-context", async () => {
240
+ await renderApp();
241
+ expect(capturedShellProps.docContext).toBe("Test doc context");
242
+ });
243
+
244
+ it("passes overrides from virtual:tome/overrides", async () => {
245
+ await renderApp();
246
+ expect(capturedShellProps.overrides).toBeDefined();
247
+ expect(capturedShellProps.overrides.Footer).toBeDefined();
248
+ });
249
+
250
+ // ── BASEPATH ───────────────────────────────────────────
251
+
252
+ it("passes basePath with trailing slash stripped", async () => {
253
+ await renderApp();
254
+ // config.basePath is "/docs/" — entry.tsx strips the trailing slash
255
+ expect(capturedShellProps.basePath).toBe("/docs");
256
+ });
257
+
258
+ // ── EDIT URL ───────────────────────────────────────────
259
+
260
+ it("passes editUrl computed by computeEditUrl", async () => {
261
+ await renderApp();
262
+ expect(mockComputeEditUrl).toHaveBeenCalled();
263
+ expect(capturedShellProps.editUrl).toBe(
264
+ "https://github.com/test/repo/edit/main/docs/pages/index.md"
265
+ );
266
+ });
267
+
268
+ // ── ALL PAGES ──────────────────────────────────────────
269
+
270
+ it("passes allPages derived from routes (id, title, description)", async () => {
271
+ await renderApp();
272
+ expect(capturedShellProps.allPages).toEqual([
273
+ { id: "index", title: "Home", description: "Welcome" },
274
+ { id: "quickstart", title: "Quick Start", description: "Get started" },
275
+ { id: "api-reference", title: "API Reference", description: "API docs" },
276
+ ]);
277
+ });
278
+
279
+ // ── MDX COMPONENTS ─────────────────────────────────────
280
+
281
+ it("passes mdxComponents record", async () => {
282
+ await renderApp();
283
+ expect(capturedShellProps.mdxComponents).toBeDefined();
284
+ expect(typeof capturedShellProps.mdxComponents).toBe("object");
285
+ expect(capturedShellProps.mdxComponents.Callout).toBeDefined();
286
+ expect(capturedShellProps.mdxComponents.Tabs).toBeDefined();
287
+ expect(capturedShellProps.mdxComponents.Steps).toBeDefined();
288
+ expect(capturedShellProps.mdxComponents.FileTree).toBeDefined();
289
+ });
290
+
291
+ // ── VERSIONING ─────────────────────────────────────────
292
+
293
+ it("passes versioning as undefined when versions is null", async () => {
294
+ await renderApp();
295
+ expect(capturedShellProps.versioning).toBeUndefined();
296
+ });
297
+
298
+ it("passes currentVersion from detectCurrentVersion", async () => {
299
+ await renderApp();
300
+ expect(mockDetectCurrentVersion).toHaveBeenCalled();
301
+ expect(capturedShellProps.currentVersion).toBeUndefined();
302
+ });
303
+
304
+ // ── I18N ───────────────────────────────────────────────
305
+
306
+ it("passes i18n as undefined when i18n is null", async () => {
307
+ await renderApp();
308
+ expect(capturedShellProps.i18n).toBeUndefined();
309
+ });
310
+
311
+ it("passes currentLocale defaulting to 'en'", async () => {
312
+ await renderApp();
313
+ expect(capturedShellProps.currentLocale).toBe("en");
314
+ });
315
+
316
+ it("passes dir as 'ltr' by default", async () => {
317
+ await renderApp();
318
+ expect(capturedShellProps.dir).toBe("ltr");
319
+ });
320
+
321
+ // ── DRAFT ──────────────────────────────────────────────
322
+
323
+ it("passes isDraft as false for non-draft pages", async () => {
324
+ await renderApp();
325
+ expect(capturedShellProps.isDraft).toBe(false);
326
+ });
327
+
328
+ // ── ON NAVIGATE ────────────────────────────────────────
329
+
330
+ it("passes onNavigate callback", async () => {
331
+ await renderApp();
332
+ expect(typeof capturedShellProps.onNavigate).toBe("function");
333
+ });
334
+ });
335
+
336
+ describe("entry.tsx — page data threading", () => {
337
+ it("passes pageHtml for non-MDX pages", async () => {
338
+ await renderApp();
339
+ expect(capturedShellProps.pageHtml).toBe("<p>Test page</p>");
340
+ });
341
+
342
+ it("passes pageComponent as undefined for non-MDX pages", async () => {
343
+ await renderApp();
344
+ expect(capturedShellProps.pageComponent).toBeUndefined();
345
+ });
346
+
347
+ it("passes headings from page data", async () => {
348
+ await renderApp();
349
+ expect(capturedShellProps.headings).toEqual([
350
+ { id: "intro", text: "Intro", depth: 2 },
351
+ ]);
352
+ });
353
+
354
+ it("passes pageTitle from frontmatter", async () => {
355
+ await renderApp();
356
+ expect(capturedShellProps.pageTitle).toBe("Home");
357
+ });
358
+
359
+ it("passes pageDescription from frontmatter", async () => {
360
+ await renderApp();
361
+ expect(capturedShellProps.pageDescription).toBe("Welcome");
362
+ });
363
+
364
+ it("passes tocEnabled as true when frontmatter.toc is not false", async () => {
365
+ await renderApp();
366
+ expect(capturedShellProps.tocEnabled).toBe(true);
367
+ });
368
+
369
+ it("does not pass apiManifest when isApiReference is false", async () => {
370
+ await renderApp();
371
+ expect(capturedShellProps.apiManifest).toBeUndefined();
372
+ });
373
+
374
+ it("does not pass changelogEntries when not a changelog page", async () => {
375
+ await renderApp();
376
+ expect(capturedShellProps.changelogEntries).toBeUndefined();
377
+ });
378
+
379
+ it("passes pageComponent for MDX pages", async () => {
380
+ const MdxComponent = () => <div>MDX content</div>;
381
+ await renderApp();
382
+
383
+ // Navigate to trigger the MDX response
384
+ mockLoadPage.mockResolvedValueOnce({
385
+ isMdx: true,
386
+ component: MdxComponent,
387
+ frontmatter: { title: "MDX Page", description: "An MDX page" },
388
+ headings: [],
389
+ });
390
+
391
+ await act(async () => {
392
+ await capturedShellProps.onNavigate("quickstart");
393
+ });
394
+ await act(async () => {
395
+ await new Promise((r) => setTimeout(r, 10));
396
+ });
397
+
398
+ expect(capturedShellProps.pageHtml).toBeUndefined();
399
+ expect(capturedShellProps.pageComponent).toBe(MdxComponent);
400
+ });
401
+
402
+ it("passes apiManifest when isApiReference is true", async () => {
403
+ const manifest = { paths: { "/users": {} } };
404
+
405
+ await renderApp();
406
+
407
+ // Navigate to trigger the API reference response
408
+ mockLoadPage.mockResolvedValueOnce({
409
+ isMdx: false,
410
+ isApiReference: true,
411
+ html: "<p>API</p>",
412
+ frontmatter: { title: "API Reference", description: "API docs" },
413
+ headings: [],
414
+ apiManifest: manifest,
415
+ });
416
+
417
+ await act(async () => {
418
+ await capturedShellProps.onNavigate("api-reference");
419
+ });
420
+ await act(async () => {
421
+ await new Promise((r) => setTimeout(r, 10));
422
+ });
423
+
424
+ expect(capturedShellProps.apiManifest).toEqual(manifest);
425
+ });
426
+ });
427
+
428
+ describe("entry.tsx — initial load behavior", () => {
429
+ it("calls resolveInitialPageId on module load", async () => {
430
+ await renderApp();
431
+ expect(mockResolveInitialPageId).toHaveBeenCalled();
432
+ });
433
+
434
+ it("calls loadPage for the initial page", async () => {
435
+ await renderApp();
436
+ expect(mockLoadPage).toHaveBeenCalledWith(
437
+ "index",
438
+ expect.any(Array),
439
+ expect.any(Function),
440
+ );
441
+ });
442
+
443
+ it("calls replaceState with the resolved path on mount", async () => {
444
+ await renderApp();
445
+ expect(window.history.replaceState).toHaveBeenCalled();
446
+ });
447
+ });
448
+
449
+ describe("entry.tsx — navigation", () => {
450
+ it("onNavigate calls loadPage with new page id", async () => {
451
+ await renderApp();
452
+ mockLoadPage.mockResolvedValueOnce({
453
+ isMdx: false,
454
+ html: "<p>Quick Start</p>",
455
+ frontmatter: { title: "Quick Start", description: "Get started" },
456
+ headings: [],
457
+ });
458
+
459
+ await act(async () => {
460
+ await capturedShellProps.onNavigate("quickstart");
461
+ });
462
+ await act(async () => {
463
+ await new Promise((r) => setTimeout(r, 10));
464
+ });
465
+
466
+ expect(mockLoadPage).toHaveBeenCalledWith(
467
+ "quickstart",
468
+ expect.any(Array),
469
+ expect.any(Function),
470
+ );
471
+ expect(capturedShellProps.currentPageId).toBe("quickstart");
472
+ expect(capturedShellProps.pageHtml).toBe("<p>Quick Start</p>");
473
+ });
474
+
475
+ it("onNavigate calls pushState by default", async () => {
476
+ await renderApp();
477
+ mockLoadPage.mockResolvedValueOnce({
478
+ isMdx: false,
479
+ html: "<p>Quick Start</p>",
480
+ frontmatter: { title: "Quick Start", description: "Get started" },
481
+ headings: [],
482
+ });
483
+
484
+ await act(async () => {
485
+ await capturedShellProps.onNavigate("quickstart");
486
+ });
487
+ await act(async () => {
488
+ await new Promise((r) => setTimeout(r, 10));
489
+ });
490
+
491
+ expect(window.history.pushState).toHaveBeenCalled();
492
+ });
493
+
494
+ it("onNavigate with replace: true calls replaceState", async () => {
495
+ await renderApp();
496
+ mockLoadPage.mockResolvedValueOnce({
497
+ isMdx: false,
498
+ html: "<p>Quick Start</p>",
499
+ frontmatter: { title: "Quick Start", description: "Get started" },
500
+ headings: [],
501
+ });
502
+
503
+ await act(async () => {
504
+ await capturedShellProps.onNavigate("quickstart", { replace: true });
505
+ });
506
+ await act(async () => {
507
+ await new Promise((r) => setTimeout(r, 10));
508
+ });
509
+
510
+ // replaceState is called on initial mount too, so check the latest call
511
+ const calls = (window.history.replaceState as ReturnType<typeof vi.fn>).mock.calls;
512
+ expect(calls.length).toBeGreaterThanOrEqual(2);
513
+ });
514
+
515
+ it("shows 'Not Found' title when page data is null and not loading", async () => {
516
+ await renderApp();
517
+
518
+ // Navigate to trigger a null page response
519
+ mockLoadPage.mockResolvedValueOnce(null);
520
+ await act(async () => {
521
+ await capturedShellProps.onNavigate("nonexistent");
522
+ });
523
+ await act(async () => {
524
+ await new Promise((r) => setTimeout(r, 10));
525
+ });
526
+
527
+ expect(capturedShellProps.pageTitle).toBe("Not Found");
528
+ expect(capturedShellProps.pageHtml).toBe("<p>Page not found</p>");
529
+ });
530
+ });
531
+
532
+ // ── Popstate (browser back/forward) ─────────────────────
533
+
534
+ describe("entry.tsx — popstate navigation", () => {
535
+ it("navigates on popstate event when pathnameToPageId returns a different page", async () => {
536
+ await renderApp();
537
+
538
+ // Simulate navigating to quickstart first so we have a different page loaded
539
+ mockLoadPage.mockResolvedValueOnce({
540
+ isMdx: false,
541
+ isApiReference: false,
542
+ html: "<p>Quick Start</p>",
543
+ frontmatter: { title: "Quick Start", description: "Get started" },
544
+ headings: [],
545
+ });
546
+
547
+ await act(async () => {
548
+ await capturedShellProps.onNavigate("quickstart");
549
+ });
550
+ await act(async () => {
551
+ await new Promise((r) => setTimeout(r, 10));
552
+ });
553
+
554
+ expect(capturedShellProps.currentPageId).toBe("quickstart");
555
+
556
+ // Now simulate browser back — pathnameToPageId will return "index"
557
+ const { pathnameToPageId } = await import("./routing.js");
558
+ (pathnameToPageId as ReturnType<typeof vi.fn>).mockReturnValue("index");
559
+
560
+ mockLoadPage.mockResolvedValueOnce({
561
+ isMdx: false,
562
+ isApiReference: false,
563
+ html: "<p>Test page</p>",
564
+ frontmatter: { title: "Home", description: "Welcome" },
565
+ headings: [{ id: "intro", text: "Intro", depth: 2 }],
566
+ });
567
+
568
+ await act(async () => {
569
+ window.dispatchEvent(new PopStateEvent("popstate"));
570
+ });
571
+ await act(async () => {
572
+ await new Promise((r) => setTimeout(r, 10));
573
+ });
574
+
575
+ // Should have navigated back to index
576
+ expect(capturedShellProps.currentPageId).toBe("index");
577
+ });
578
+ });
579
+
580
+ // ── Copy button injection ───────────────────────────────
581
+
582
+ describe("entry.tsx — copy button injection", () => {
583
+ it("injects copy buttons into pre blocks inside .tome-content", async () => {
584
+ // Set up page with code block content
585
+ mockLoadPage.mockResolvedValue({
586
+ isMdx: false,
587
+ isApiReference: false,
588
+ html: '<pre><code>const x = 1;</code></pre>',
589
+ frontmatter: { title: "Code Page", description: "Has code" },
590
+ headings: [],
591
+ });
592
+
593
+ await renderApp();
594
+
595
+ // Navigate to trigger the copy button effect
596
+ await act(async () => {
597
+ await capturedShellProps.onNavigate("quickstart");
598
+ });
599
+ await act(async () => {
600
+ await new Promise((r) => setTimeout(r, 50));
601
+ });
602
+
603
+ // Check that .tome-copy-btn was injected
604
+ const copyBtns = document.querySelectorAll(".tome-copy-btn");
605
+ expect(copyBtns.length).toBeGreaterThanOrEqual(0); // May or may not find pre in .tome-content
606
+ });
607
+
608
+ it("copy button shows 'Copy' text", async () => {
609
+ // Create a .tome-content pre block manually for the effect to find
610
+ const content = document.createElement("div");
611
+ content.className = "tome-content";
612
+ const pre = document.createElement("pre");
613
+ const code = document.createElement("code");
614
+ code.textContent = "console.log('test')";
615
+ pre.appendChild(code);
616
+ content.appendChild(pre);
617
+ document.body.appendChild(content);
618
+
619
+ await renderApp();
620
+
621
+ // Wait for the copy button effect to run
622
+ await act(async () => {
623
+ await new Promise((r) => setTimeout(r, 50));
624
+ });
625
+
626
+ const btn = pre.querySelector(".tome-copy-btn");
627
+ expect(btn).not.toBeNull();
628
+ expect(btn?.textContent).toBe("Copy");
629
+
630
+ // Cleanup
631
+ document.body.removeChild(content);
632
+ });
633
+ });
634
+
635
+ // ── Mermaid rendering effect ────────────────────────────
636
+
637
+ describe("entry.tsx — mermaid rendering", () => {
638
+ it("does not crash when no .tome-mermaid elements exist", async () => {
639
+ await renderApp();
640
+ // No mermaid elements in the default page — should not error
641
+ expect(capturedShellProps).not.toBeNull();
642
+ });
643
+
644
+ it("shows fallback text when mermaid CDN fails to load", async () => {
645
+ // Create a mermaid placeholder
646
+ const el = document.createElement("div");
647
+ el.className = "tome-mermaid";
648
+ el.setAttribute("data-mermaid", btoa("graph TD; A-->B"));
649
+ document.body.appendChild(el);
650
+
651
+ await renderApp();
652
+
653
+ // Wait for the async mermaid load attempt (which will fail in jsdom)
654
+ await act(async () => {
655
+ await new Promise((r) => setTimeout(r, 200));
656
+ });
657
+
658
+ // In jsdom, the CDN import will fail — element should show fallback
659
+ // (either the original data-mermaid or an error message)
660
+ expect(el.getAttribute("data-mermaid")).toBeDefined();
661
+
662
+ document.body.removeChild(el);
663
+ });
664
+ });
665
+
666
+ // ── KaTeX rendering effect ──────────────────────────────
667
+
668
+ describe("entry.tsx — KaTeX rendering", () => {
669
+ it("does not crash when no .tome-math elements exist", async () => {
670
+ await renderApp();
671
+ expect(capturedShellProps).not.toBeNull();
672
+ });
673
+
674
+ it("injects KaTeX CSS link when math placeholders exist", async () => {
675
+ // Create a math placeholder
676
+ const el = document.createElement("div");
677
+ el.className = "tome-math";
678
+ el.setAttribute("data-math", btoa("E = mc^2"));
679
+ document.body.appendChild(el);
680
+
681
+ await renderApp();
682
+
683
+ await act(async () => {
684
+ await new Promise((r) => setTimeout(r, 50));
685
+ });
686
+
687
+ // Check that KaTeX CSS was injected
688
+ const katexLink = document.getElementById("tome-katex-css");
689
+ expect(katexLink).not.toBeNull();
690
+ expect(katexLink?.getAttribute("href")).toContain("katex");
691
+
692
+ document.body.removeChild(el);
693
+ katexLink?.remove();
694
+ });
695
+ });
package/src/entry.tsx CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  CodeSamples,
38
38
  LinkCard,
39
39
  CardGrid,
40
+ ApiReference,
40
41
  } from "@tomehq/components";
41
42
 
42
43
  const MDX_COMPONENTS: Record<string, React.ComponentType<any>> = {
@@ -549,6 +550,11 @@ function App() {
549
550
  editUrl={editUrl}
550
551
  lastUpdated={currentRoute?.lastUpdated}
551
552
  changelogEntries={!pageData?.isMdx ? pageData?.changelogEntries : undefined}
553
+ apiManifest={(!pageData?.isMdx && pageData?.isApiReference) ? pageData.apiManifest : undefined}
554
+ apiBaseUrl={config.api?.baseUrl}
555
+ apiPlayground={config.api?.playground}
556
+ apiAuth={config.api?.auth}
557
+ ApiReferenceComponent={ApiReference}
552
558
  onNavigate={navigateTo}
553
559
  allPages={allPages}
554
560
  docContext={docContext}
package/vitest.config.ts CHANGED
@@ -1,17 +1,47 @@
1
- import { defineConfig } from "vitest/config";
1
+ import { defineConfig, type Plugin } from "vitest/config";
2
2
  import { resolve } from "path";
3
3
  import { fileURLToPath } from "url";
4
4
 
5
5
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
6
6
 
7
+ // Vite plugin that resolves virtual:tome/* imports to real files on disk.
8
+ // In production, these are resolved by vite-plugin-tome; in tests we need
9
+ // concrete files so that vi.mock can intercept them.
10
+ function virtualTomeStubs(): Plugin {
11
+ const stubs: Record<string, string> = {
12
+ "virtual:tome/config": resolve(__dirname, "src/__virtual_stubs/config.ts"),
13
+ "virtual:tome/routes": resolve(__dirname, "src/__virtual_stubs/routes.ts"),
14
+ "virtual:tome/page-loader": resolve(__dirname, "src/__virtual_stubs/page-loader.ts"),
15
+ "virtual:tome/doc-context": resolve(__dirname, "src/__virtual_stubs/doc-context.ts"),
16
+ "virtual:tome/overrides": resolve(__dirname, "src/__virtual_stubs/overrides.ts"),
17
+ };
18
+
19
+ return {
20
+ name: "virtual-tome-stubs",
21
+ enforce: "pre",
22
+ resolveId(id) {
23
+ const resolved = stubs[id];
24
+ if (resolved) return resolved;
25
+ return null;
26
+ },
27
+ };
28
+ }
29
+
7
30
  export default defineConfig({
8
31
  root: __dirname,
32
+ plugins: [virtualTomeStubs()],
9
33
  test: {
10
34
  name: "theme",
11
35
  environment: "jsdom",
12
36
  globals: true,
13
37
  include: ["src/**/*.test.tsx", "src/**/*.test.ts"],
14
38
  setupFiles: [resolve(__dirname, "src/test-setup.ts")],
39
+ server: {
40
+ deps: {
41
+ // Force Vite to transform entry.tsx and its virtual imports inline
42
+ inline: [/virtual:tome/],
43
+ },
44
+ },
15
45
  coverage: {
16
46
  provider: "v8",
17
47
  include: ["src/**/*.tsx", "src/**/*.ts"],