@tomehq/theme 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -182,22 +182,121 @@ describe("Shell theme mode", () => {
182
182
  });
183
183
  });
184
184
 
185
- // ── TOC ───────────────────────────────────────────────────
185
+ // ── TOC (TOM-52) ──────────────────────────────────────────
186
186
 
187
187
  describe("Shell table of contents", () => {
188
- it("renders TOC headings when headings are provided", () => {
188
+ beforeEach(() => {
189
189
  // jsdom window.innerWidth defaults to 0 so 'wide' will be false — we need to set it
190
190
  Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1400 });
191
191
  fireEvent(window, new Event("resize"));
192
+ });
192
193
 
193
- renderShell({
194
- headings: [
195
- { depth: 2, text: "Overview", id: "overview" },
196
- { depth: 3, text: "Details", id: "details" },
197
- ],
198
- });
194
+ const sampleHeadings = [
195
+ { depth: 2, text: "Overview", id: "overview" },
196
+ { depth: 3, text: "Details", id: "details" },
197
+ { depth: 2, text: "Usage", id: "usage" },
198
+ ];
199
+
200
+ it("renders TOC headings when headings are provided", () => {
201
+ renderShell({ headings: sampleHeadings });
202
+ expect(screen.getByTestId("toc-sidebar")).toBeInTheDocument();
199
203
  expect(screen.getByText("Overview")).toBeInTheDocument();
200
204
  expect(screen.getByText("Details")).toBeInTheDocument();
205
+ expect(screen.getByText("Usage")).toBeInTheDocument();
206
+ });
207
+
208
+ it("renders 'On this page' label", () => {
209
+ renderShell({ headings: sampleHeadings });
210
+ expect(screen.getByText("On this page")).toBeInTheDocument();
211
+ });
212
+
213
+ it("renders TOC links with correct href attributes", () => {
214
+ renderShell({ headings: sampleHeadings });
215
+ const overviewLink = screen.getByTestId("toc-link-overview");
216
+ expect(overviewLink).toHaveAttribute("href", "#overview");
217
+ const detailsLink = screen.getByTestId("toc-link-details");
218
+ expect(detailsLink).toHaveAttribute("href", "#details");
219
+ });
220
+
221
+ it("renders TOC inside a nav element with aria-label", () => {
222
+ renderShell({ headings: sampleHeadings });
223
+ const nav = screen.getByRole("navigation", { name: "Table of contents" });
224
+ expect(nav).toBeInTheDocument();
225
+ });
226
+
227
+ it("does not render TOC when fewer than 2 headings", () => {
228
+ renderShell({
229
+ headings: [{ depth: 2, text: "Only One", id: "only-one" }],
230
+ });
231
+ expect(screen.queryByTestId("toc-sidebar")).not.toBeInTheDocument();
232
+ });
233
+
234
+ it("does not render TOC when headings array is empty", () => {
235
+ renderShell({ headings: [] });
236
+ expect(screen.queryByTestId("toc-sidebar")).not.toBeInTheDocument();
237
+ });
238
+
239
+ it("hides TOC when tocEnabled is false (frontmatter toc: false)", () => {
240
+ renderShell({ headings: sampleHeadings, tocEnabled: false });
241
+ expect(screen.queryByTestId("toc-sidebar")).not.toBeInTheDocument();
242
+ });
243
+
244
+ it("hides TOC when config toc.enabled is false", () => {
245
+ renderShell({
246
+ headings: sampleHeadings,
247
+ config: { ...baseConfig, toc: { enabled: false } },
248
+ });
249
+ expect(screen.queryByTestId("toc-sidebar")).not.toBeInTheDocument();
250
+ });
251
+
252
+ it("filters headings by config toc.depth", () => {
253
+ const headingsWithH4 = [
254
+ { depth: 2, text: "Section", id: "section" },
255
+ { depth: 3, text: "Subsection", id: "subsection" },
256
+ { depth: 4, text: "Deep", id: "deep" },
257
+ ];
258
+ // depth: 2 means only h2 headings
259
+ renderShell({
260
+ headings: headingsWithH4,
261
+ config: { ...baseConfig, toc: { depth: 2 } },
262
+ });
263
+ // Only h2 shown, but need at least 2 headings — only 1 h2 so TOC hidden
264
+ expect(screen.queryByTestId("toc-sidebar")).not.toBeInTheDocument();
265
+ });
266
+
267
+ it("shows only headings up to configured depth", () => {
268
+ const headingsMultiDepth = [
269
+ { depth: 2, text: "First", id: "first" },
270
+ { depth: 2, text: "Second", id: "second" },
271
+ { depth: 3, text: "Sub", id: "sub" },
272
+ { depth: 4, text: "Deep", id: "deep" },
273
+ ];
274
+ // depth: 3 means h2 + h3, but not h4
275
+ renderShell({
276
+ headings: headingsMultiDepth,
277
+ config: { ...baseConfig, toc: { depth: 3 } },
278
+ });
279
+ expect(screen.getByTestId("toc-sidebar")).toBeInTheDocument();
280
+ expect(screen.getByText("First")).toBeInTheDocument();
281
+ expect(screen.getByText("Second")).toBeInTheDocument();
282
+ expect(screen.getByText("Sub")).toBeInTheDocument();
283
+ expect(screen.queryByTestId("toc-link-deep")).not.toBeInTheDocument();
284
+ });
285
+
286
+ it("hides TOC on narrow viewports", () => {
287
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 800 });
288
+ fireEvent(window, new Event("resize"));
289
+
290
+ renderShell({ headings: sampleHeadings });
291
+ expect(screen.queryByTestId("toc-sidebar")).not.toBeInTheDocument();
292
+ });
293
+
294
+ it("shows TOC on wide viewports", () => {
295
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1400 });
296
+ fireEvent(window, new Event("resize"));
297
+
298
+ renderShell({ headings: sampleHeadings });
299
+ expect(screen.getByTestId("toc-sidebar")).toBeInTheDocument();
201
300
  });
202
301
  });
203
302
 
@@ -541,6 +640,141 @@ describe("Shell language switcher", () => {
541
640
  });
542
641
  });
543
642
 
643
+ // ── Edit this page on GitHub (TOM-48) ────────────────────
644
+
645
+ describe("Shell edit link", () => {
646
+ it("renders edit link when editUrl is provided", () => {
647
+ renderShell({ editUrl: "https://github.com/org/repo/edit/main/docs/intro.md" });
648
+ const link = screen.getByTestId("edit-page-link");
649
+ expect(link).toBeInTheDocument();
650
+ expect(link.querySelector("a")?.getAttribute("href")).toBe(
651
+ "https://github.com/org/repo/edit/main/docs/intro.md"
652
+ );
653
+ });
654
+
655
+ it("edit link opens in new tab", () => {
656
+ renderShell({ editUrl: "https://github.com/org/repo/edit/main/docs/intro.md" });
657
+ const anchor = screen.getByTestId("edit-page-link").querySelector("a");
658
+ expect(anchor?.getAttribute("target")).toBe("_blank");
659
+ expect(anchor?.getAttribute("rel")).toBe("noopener noreferrer");
660
+ });
661
+
662
+ it("shows 'Edit this page on GitHub' text", () => {
663
+ renderShell({ editUrl: "https://github.com/org/repo/edit/main/docs/intro.md" });
664
+ expect(screen.getByText("Edit this page on GitHub")).toBeInTheDocument();
665
+ });
666
+
667
+ it("does not render edit link when editUrl is not provided", () => {
668
+ renderShell();
669
+ expect(screen.queryByTestId("edit-page-link")).not.toBeInTheDocument();
670
+ });
671
+
672
+ it("does not render edit link when editUrl is undefined", () => {
673
+ renderShell({ editUrl: undefined });
674
+ expect(screen.queryByTestId("edit-page-link")).not.toBeInTheDocument();
675
+ });
676
+ });
677
+
678
+ // ── Last Updated (TOM-54) ────────────────────────────────
679
+
680
+ describe("Shell last updated", () => {
681
+ it("renders last updated when lastUpdated is provided", () => {
682
+ renderShell({ lastUpdated: "2025-01-15T10:00:00Z" });
683
+ const el = screen.getByTestId("last-updated");
684
+ expect(el).toBeInTheDocument();
685
+ expect(el.textContent).toContain("Last updated");
686
+ });
687
+
688
+ it("does not render last updated when lastUpdated is not provided", () => {
689
+ renderShell();
690
+ expect(screen.queryByTestId("last-updated")).not.toBeInTheDocument();
691
+ });
692
+
693
+ it("does not render last updated when lastUpdated is undefined", () => {
694
+ renderShell({ lastUpdated: undefined });
695
+ expect(screen.queryByTestId("last-updated")).not.toBeInTheDocument();
696
+ });
697
+
698
+ it("renders both edit link and last updated when both provided", () => {
699
+ renderShell({
700
+ editUrl: "https://github.com/org/repo/edit/main/docs/intro.md",
701
+ lastUpdated: "2025-06-01T12:00:00Z",
702
+ });
703
+ expect(screen.getByTestId("edit-page-link")).toBeInTheDocument();
704
+ expect(screen.getByTestId("last-updated")).toBeInTheDocument();
705
+ });
706
+
707
+ it("shows relative date text for recent date", () => {
708
+ // Use a date from "1 day ago" to test relative formatting
709
+ const yesterday = new Date(Date.now() - 86400000).toISOString();
710
+ renderShell({ lastUpdated: yesterday });
711
+ const el = screen.getByTestId("last-updated");
712
+ expect(el.textContent).toContain("Last updated 1 day ago");
713
+ });
714
+ });
715
+
716
+ // ── Changelog (TOM-49) ──────────────────────────────────
717
+
718
+ describe("Shell changelog rendering", () => {
719
+ const sampleEntries = [
720
+ {
721
+ version: "2.0.0",
722
+ date: "2025-06-01",
723
+ sections: [
724
+ { type: "Added", items: ["New feature", "Another feature"] },
725
+ { type: "Fixed", items: ["Bug fix"] },
726
+ ],
727
+ },
728
+ {
729
+ version: "1.0.0",
730
+ date: "2025-01-15",
731
+ sections: [
732
+ { type: "Added", items: ["Initial release"] },
733
+ ],
734
+ },
735
+ ];
736
+
737
+ it("renders changelog timeline when entries provided", () => {
738
+ renderShell({ changelogEntries: sampleEntries });
739
+ expect(screen.getByTestId("changelog-timeline")).toBeInTheDocument();
740
+ });
741
+
742
+ it("renders each changelog entry", () => {
743
+ renderShell({ changelogEntries: sampleEntries });
744
+ expect(screen.getByTestId("changelog-entry-2.0.0")).toBeInTheDocument();
745
+ expect(screen.getByTestId("changelog-entry-1.0.0")).toBeInTheDocument();
746
+ });
747
+
748
+ it("renders version text and dates", () => {
749
+ renderShell({ changelogEntries: sampleEntries });
750
+ expect(screen.getByText("2.0.0")).toBeInTheDocument();
751
+ expect(screen.getByText("2025-06-01")).toBeInTheDocument();
752
+ });
753
+
754
+ it("renders section labels", () => {
755
+ renderShell({ changelogEntries: sampleEntries });
756
+ expect(screen.getAllByText("Added").length).toBeGreaterThan(0);
757
+ expect(screen.getByText("Fixed")).toBeInTheDocument();
758
+ });
759
+
760
+ it("renders change items", () => {
761
+ renderShell({ changelogEntries: sampleEntries });
762
+ expect(screen.getByText("New feature")).toBeInTheDocument();
763
+ expect(screen.getByText("Bug fix")).toBeInTheDocument();
764
+ expect(screen.getByText("Initial release")).toBeInTheDocument();
765
+ });
766
+
767
+ it("does not render changelog when no entries provided", () => {
768
+ renderShell();
769
+ expect(screen.queryByTestId("changelog-timeline")).not.toBeInTheDocument();
770
+ });
771
+
772
+ it("does not render changelog when entries array is empty", () => {
773
+ renderShell({ changelogEntries: [] });
774
+ expect(screen.queryByTestId("changelog-timeline")).not.toBeInTheDocument();
775
+ });
776
+ });
777
+
544
778
  // ── AI Chat (TOM-32) ─────────────────────────────────────
545
779
 
546
780
  describe("Shell AI chat integration", () => {
package/src/Shell.tsx CHANGED
@@ -44,6 +44,28 @@ const MoonIcon = () => <Icon d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
44
44
  const SunIcon = () => <Icon d="M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Z" />;
45
45
  const ArrowLeft = () => <Icon d="M19 12H5M12 19l-7-7 7-7" size={14} />;
46
46
  const ArrowRight = () => <Icon d="M5 12h14M12 5l7 7-7 7" size={14} />;
47
+ const PencilIcon = () => <Icon d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" size={13} />;
48
+
49
+ // ── RELATIVE DATE FORMATTER (TOM-54) ─────────────────────
50
+ function formatRelativeDate(isoDate: string): string {
51
+ const date = new Date(isoDate);
52
+ const now = new Date();
53
+ const diffMs = now.getTime() - date.getTime();
54
+ if (isNaN(diffMs)) return "";
55
+ const seconds = Math.floor(diffMs / 1000);
56
+ const minutes = Math.floor(seconds / 60);
57
+ const hours = Math.floor(minutes / 60);
58
+ const days = Math.floor(hours / 24);
59
+ const months = Math.floor(days / 30);
60
+ const years = Math.floor(days / 365);
61
+ if (seconds < 60) return "just now";
62
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
63
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
64
+ if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
65
+ if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`;
66
+ if (years >= 1) return `${years} year${years === 1 ? "" : "s"} ago`;
67
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
68
+ }
47
69
 
48
70
  // ── PAGEFIND CLIENT (TOM-15) ─────────────────────────────
49
71
  let pagefindInstance: any = null;
@@ -195,12 +217,79 @@ export interface I18nInfo {
195
217
  localeNames?: Record<string, string>;
196
218
  }
197
219
 
220
+ // ── CHANGELOG VIEW (TOM-49) ─────────────────────────────
221
+ const CHANGELOG_SECTION_COLORS: Record<string, string> = {
222
+ Added: "#22c55e", Changed: "#3b82f6", Deprecated: "#f59e0b",
223
+ Removed: "#ef4444", Fixed: "#8b5cf6", Security: "#f97316",
224
+ };
225
+
226
+ interface ChangelogViewEntry {
227
+ version: string;
228
+ date?: string;
229
+ url?: string;
230
+ sections: Array<{ type: string; items: string[] }>;
231
+ }
232
+
233
+ function ChangelogView({ entries }: { entries: ChangelogViewEntry[] }) {
234
+ const [showAll, setShowAll] = useState(entries.length <= 5);
235
+ const visible = showAll ? entries : entries.slice(0, 5);
236
+
237
+ return (
238
+ <div data-testid="changelog-timeline" style={{ position: "relative" }}>
239
+ <div style={{ position: "absolute", left: 15, top: 8, bottom: 8, width: 2, background: "var(--bd)" }} />
240
+ {visible.map((entry, i) => (
241
+ <div key={entry.version} data-testid={`changelog-entry-${entry.version}`}
242
+ style={{ position: "relative", paddingLeft: 44, paddingBottom: i < visible.length - 1 ? 32 : 0 }}>
243
+ <div style={{
244
+ position: "absolute", left: 8, top: 6, width: 16, height: 16, borderRadius: "50%",
245
+ background: entry.version === "Unreleased" ? "var(--txM)" : "var(--ac)",
246
+ border: "3px solid var(--bg, #1a1a1a)",
247
+ }} />
248
+ <div style={{ display: "flex", alignItems: "baseline", gap: 12, marginBottom: 12 }}>
249
+ <span style={{ fontSize: 18, fontWeight: 700, color: "var(--tx)", fontFamily: "var(--font-heading, inherit)" }}>
250
+ {entry.url ? (
251
+ <a href={entry.url} target="_blank" rel="noopener noreferrer" style={{ color: "inherit", textDecoration: "none" }}>{entry.version}</a>
252
+ ) : entry.version}
253
+ </span>
254
+ {entry.date && <span style={{ fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-code, monospace)" }}>{entry.date}</span>}
255
+ </div>
256
+ {entry.sections.map((section) => {
257
+ const sColor = CHANGELOG_SECTION_COLORS[section.type] || "#6b7280";
258
+ return (
259
+ <div key={section.type} style={{ marginBottom: 16 }}>
260
+ <div style={{ display: "inline-flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
261
+ <span style={{ display: "inline-block", width: 8, height: 8, borderRadius: "50%", background: sColor }} />
262
+ <span style={{ fontSize: 12, fontWeight: 600, textTransform: "uppercase", letterSpacing: ".06em", color: sColor, fontFamily: "var(--font-code, monospace)" }}>{section.type}</span>
263
+ </div>
264
+ <ul style={{ margin: 0, paddingLeft: 18, listStyleType: "disc", color: "var(--tx2)" }}>
265
+ {section.items.map((item, j) => (
266
+ <li key={j} style={{ fontSize: 14, lineHeight: 1.7, color: "var(--tx2)", marginBottom: 2 }}>{item}</li>
267
+ ))}
268
+ </ul>
269
+ </div>
270
+ );
271
+ })}
272
+ </div>
273
+ ))}
274
+ {!showAll && entries.length > 5 && (
275
+ <div style={{ textAlign: "center", marginTop: 24 }}>
276
+ <button data-testid="changelog-show-more" onClick={() => setShowAll(true)}
277
+ style={{ background: "none", border: "1px solid var(--bd)", borderRadius: 2, padding: "8px 20px", color: "var(--tx2)", fontSize: 13, fontFamily: "var(--font-body, inherit)", cursor: "pointer" }}>
278
+ Show all {entries.length} releases
279
+ </button>
280
+ </div>
281
+ )}
282
+ </div>
283
+ );
284
+ }
285
+
198
286
  interface ShellProps {
199
287
  config: {
200
288
  name: string;
201
289
  theme?: { preset?: string; mode?: string; accent?: string; fonts?: { heading?: string; body?: string; code?: string } };
202
290
  search?: { provider?: string; appId?: string; apiKey?: string; indexName?: string };
203
291
  ai?: { enabled?: boolean; provider?: "openai" | "anthropic" | "custom"; model?: string; apiKeyEnv?: string };
292
+ toc?: { enabled?: boolean; depth?: number };
204
293
  topNav?: Array<{ label: string; href: string }>;
205
294
  [key: string]: unknown;
206
295
  };
@@ -215,6 +304,10 @@ interface ShellProps {
215
304
  pageTitle: string;
216
305
  pageDescription?: string;
217
306
  headings: Array<{ depth: number; text: string; id: string }>;
307
+ tocEnabled?: boolean;
308
+ editUrl?: string;
309
+ lastUpdated?: string;
310
+ changelogEntries?: Array<{ version: string; date?: string; url?: string; sections: Array<{ type: string; items: string[] }> }>;
218
311
  onNavigate: (id: string) => void;
219
312
  allPages: Array<{ id: string; title: string; description?: string }>;
220
313
  versioning?: VersioningInfo;
@@ -226,7 +319,7 @@ interface ShellProps {
226
319
 
227
320
  export function Shell({
228
321
  config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents,
229
- pageTitle, pageDescription, headings, onNavigate, allPages,
322
+ pageTitle, pageDescription, headings, tocEnabled = true, editUrl, lastUpdated, changelogEntries, onNavigate, allPages,
230
323
  versioning, currentVersion, i18n, currentLocale, docContext,
231
324
  }: ShellProps) {
232
325
  const themeMode = config.theme?.mode || "auto";
@@ -286,6 +379,77 @@ export function Shell({
286
379
 
287
380
  useEffect(() => { contentRef.current?.scrollTo(0, 0); }, [currentPageId]);
288
381
 
382
+ // ── TOC: Config-based depth filtering + frontmatter opt-out ──
383
+ const tocConfig = config.toc;
384
+ const tocDepth = tocConfig?.depth ?? 3;
385
+ const tocGlobalEnabled = tocConfig?.enabled !== false;
386
+ const showToc = tocGlobalEnabled && tocEnabled;
387
+ const filteredHeadings = headings.filter(h => h.depth <= tocDepth);
388
+
389
+ // ── TOC: Scroll-spy with IntersectionObserver ──
390
+ const [activeHeadingId, setActiveHeadingId] = useState<string>("");
391
+
392
+ useEffect(() => {
393
+ if (!showToc || filteredHeadings.length < 2) return;
394
+
395
+ const scrollRoot = contentRef.current;
396
+ if (!scrollRoot) return;
397
+
398
+ // Small delay to ensure DOM headings are rendered (especially after page load)
399
+ const timerId = setTimeout(() => {
400
+ const headingElements: Element[] = [];
401
+ for (const h of filteredHeadings) {
402
+ const el = scrollRoot.querySelector(`#${CSS.escape(h.id)}`);
403
+ if (el) headingElements.push(el);
404
+ }
405
+ if (headingElements.length === 0) return;
406
+
407
+ const observer = new IntersectionObserver(
408
+ (entries) => {
409
+ // Find the topmost visible heading
410
+ const visible = entries
411
+ .filter(e => e.isIntersecting)
412
+ .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
413
+ if (visible.length > 0) {
414
+ setActiveHeadingId(visible[0].target.id);
415
+ }
416
+ },
417
+ {
418
+ root: scrollRoot,
419
+ // Trigger when heading enters the top 20% of the scroll container
420
+ rootMargin: "0px 0px -80% 0px",
421
+ threshold: 0,
422
+ }
423
+ );
424
+
425
+ for (const el of headingElements) observer.observe(el);
426
+ observerRef.current = observer;
427
+ }, 100);
428
+
429
+ return () => {
430
+ clearTimeout(timerId);
431
+ observerRef.current?.disconnect();
432
+ observerRef.current = null;
433
+ };
434
+ }, [currentPageId, showToc, filteredHeadings.map(h => h.id).join(",")]);
435
+
436
+ const observerRef = useRef<IntersectionObserver | null>(null);
437
+
438
+ // Reset active heading when page changes
439
+ useEffect(() => { setActiveHeadingId(""); }, [currentPageId]);
440
+
441
+ // Smooth scroll handler for TOC links
442
+ const scrollToHeading = useCallback((e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
443
+ e.preventDefault();
444
+ const scrollRoot = contentRef.current;
445
+ if (!scrollRoot) return;
446
+ const target = scrollRoot.querySelector(`#${CSS.escape(id)}`);
447
+ if (target) {
448
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
449
+ setActiveHeadingId(id);
450
+ }
451
+ }, []);
452
+
289
453
  useEffect(() => {
290
454
  const h = (e: KeyboardEvent) => {
291
455
  if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setSearch(true); }
@@ -342,12 +506,12 @@ export function Shell({
342
506
  display: "flex", flexDirection: "column",
343
507
  transition: "width .2s, min-width .2s", overflow: "hidden",
344
508
  }}>
345
- <div style={{ padding: "18px 20px", display: "flex", alignItems: "baseline", gap: 6, borderBottom: "1px solid var(--bd)" }}>
509
+ <a href="/" style={{ padding: "18px 20px", display: "flex", alignItems: "baseline", gap: 6, borderBottom: "1px solid var(--bd)", textDecoration: "none", color: "inherit" }}>
346
510
  <span style={{ fontFamily: "var(--font-heading)", fontSize: 22, fontWeight: 700, fontStyle: "italic" }}>
347
511
  {config.name}
348
512
  </span>
349
513
  <span style={{ width: 5, height: 5, borderRadius: "50%", background: "var(--ac)", display: "inline-block" }} />
350
- </div>
514
+ </a>
351
515
 
352
516
  <div style={{ padding: "12px 14px" }}>
353
517
  <button onClick={() => setSearch(true)} style={{
@@ -607,8 +771,10 @@ export function Shell({
607
771
  </h1>
608
772
  {pageDescription && <p style={{ fontSize: 16, color: "var(--tx2)", lineHeight: 1.6, marginBottom: 32 }}>{pageDescription}</p>}
609
773
  <div style={{ borderTop: "1px solid var(--bd)", paddingTop: 28 }}>
610
- {/* TOM-8: Render MDX component or raw HTML */}
611
- {PageComponent ? (
774
+ {/* TOM-49: Changelog page type */}
775
+ {changelogEntries && changelogEntries.length > 0 ? (
776
+ <ChangelogView entries={changelogEntries} />
777
+ ) : PageComponent ? (
612
778
  <div className="tome-content">
613
779
  <PageComponent components={mdxComponents || {}} />
614
780
  </div>
@@ -620,8 +786,37 @@ export function Shell({
620
786
  )}
621
787
  </div>
622
788
 
789
+ {/* TOM-48: Edit this page link + TOM-54: Last updated */}
790
+ {(editUrl || lastUpdated) && (
791
+ <div style={{ marginTop: 40, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }}>
792
+ {editUrl && (
793
+ <div data-testid="edit-page-link">
794
+ <a
795
+ href={editUrl}
796
+ target="_blank"
797
+ rel="noopener noreferrer"
798
+ style={{
799
+ display: "inline-flex", alignItems: "center", gap: 6,
800
+ color: "var(--txM)", textDecoration: "none", fontSize: 13,
801
+ fontFamily: "var(--font-body)", transition: "color .15s",
802
+ }}
803
+ onMouseOver={(e) => (e.currentTarget.style.color = "var(--ac)")}
804
+ onMouseOut={(e) => (e.currentTarget.style.color = "var(--txM)")}
805
+ >
806
+ <PencilIcon /> Edit this page on GitHub
807
+ </a>
808
+ </div>
809
+ )}
810
+ {lastUpdated && (
811
+ <div data-testid="last-updated" style={{ fontSize: 12, color: "var(--txM)", fontFamily: "var(--font-body)" }}>
812
+ Last updated {formatRelativeDate(lastUpdated)}
813
+ </div>
814
+ )}
815
+ </div>
816
+ )}
817
+
623
818
  {/* Prev / Next */}
624
- <div style={{ display: "flex", justifyContent: "space-between", marginTop: 48, paddingTop: 24, borderTop: "1px solid var(--bd)", gap: 16 }}>
819
+ <div style={{ display: "flex", justifyContent: "space-between", marginTop: (editUrl || lastUpdated) ? 16 : 48, paddingTop: 24, borderTop: "1px solid var(--bd)", gap: 16 }}>
625
820
  {prev ? (
626
821
  <button onClick={() => onNavigate(prev.id)} style={{
627
822
  display: "flex", alignItems: "center", gap: 8, background: "none",
@@ -641,19 +836,35 @@ export function Shell({
641
836
  </div>
642
837
  </main>
643
838
 
644
- {/* TOC */}
645
- {headings.length > 0 && wide && (
646
- <aside style={{ width: 200, padding: "40px 16px 40px 0", position: "sticky", top: 0, alignSelf: "flex-start", flexShrink: 0 }}>
839
+ {/* TOC (TOM-52) */}
840
+ {showToc && filteredHeadings.length >= 2 && wide && (
841
+ <aside data-testid="toc-sidebar" style={{ width: 200, padding: "40px 16px 40px 0", position: "sticky", top: 0, alignSelf: "flex-start", flexShrink: 0 }}>
647
842
  <div style={{ fontSize: 10, fontWeight: 600, textTransform: "uppercase", letterSpacing: ".1em", color: "var(--txM)", marginBottom: 12, fontFamily: "var(--font-code)" }}>On this page</div>
648
- <div style={{ borderLeft: "1px solid var(--bd)", paddingLeft: 0 }}>
649
- {headings.map((h, i) => (
650
- <a key={i} href={`#${h.id}`} style={{
651
- display: "block", fontSize: 12, color: "var(--txM)",
652
- textDecoration: "none", padding: "4px 12px", paddingLeft: 12 + (h.depth - 2) * 12,
653
- lineHeight: 1.4, transition: "color .12s",
654
- }}>{h.text}</a>
655
- ))}
656
- </div>
843
+ <nav aria-label="Table of contents" style={{ borderLeft: "1px solid var(--bd)", paddingLeft: 0 }}>
844
+ {filteredHeadings.map((h, i) => {
845
+ const isActive = activeHeadingId === h.id;
846
+ return (
847
+ <a
848
+ key={i}
849
+ href={`#${h.id}`}
850
+ onClick={(e) => scrollToHeading(e, h.id)}
851
+ data-testid={`toc-link-${h.id}`}
852
+ style={{
853
+ display: "block", fontSize: 12,
854
+ color: isActive ? "var(--ac)" : "var(--txM)",
855
+ fontWeight: isActive ? 500 : 400,
856
+ textDecoration: "none",
857
+ padding: "4px 12px",
858
+ paddingLeft: 12 + (h.depth - 2) * 12,
859
+ lineHeight: 1.4,
860
+ transition: "color .15s, font-weight .15s",
861
+ borderLeft: isActive ? "2px solid var(--ac)" : "2px solid transparent",
862
+ marginLeft: -1,
863
+ }}
864
+ >{h.text}</a>
865
+ );
866
+ })}
867
+ </nav>
657
868
  </aside>
658
869
  )}
659
870
  </div>
package/src/entry.tsx CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  CardGroup,
21
21
  Steps,
22
22
  Accordion,
23
+ ChangelogTimeline,
23
24
  } from "@tomehq/components";
24
25
 
25
26
  const MDX_COMPONENTS: Record<string, React.ComponentType<any>> = {
@@ -29,6 +30,7 @@ const MDX_COMPONENTS: Record<string, React.ComponentType<any>> = {
29
30
  CardGroup,
30
31
  Steps,
31
32
  Accordion,
33
+ ChangelogTimeline,
32
34
  };
33
35
 
34
36
  // ── CONTENT STYLES ───────────────────────────────────────
@@ -82,14 +84,15 @@ const contentStyles = `
82
84
  interface HtmlPage {
83
85
  isMdx: false;
84
86
  html: string;
85
- frontmatter: { title: string; description?: string };
87
+ frontmatter: { title: string; description?: string; toc?: boolean; type?: string };
86
88
  headings: Array<{ depth: number; text: string; id: string }>;
89
+ changelogEntries?: Array<{ version: string; date?: string; url?: string; sections: Array<{ type: string; items: string[] }> }>;
87
90
  }
88
91
 
89
92
  interface MdxPage {
90
93
  isMdx: true;
91
94
  component: React.ComponentType<{ components?: Record<string, React.ComponentType> }>;
92
- frontmatter: { title: string; description?: string };
95
+ frontmatter: { title: string; description?: string; toc?: boolean; type?: string };
93
96
  headings: Array<{ depth: number; text: string; id: string }>;
94
97
  }
95
98
 
@@ -113,6 +116,12 @@ async function loadPage(id: string): Promise<LoadedPage | null> {
113
116
 
114
117
  // Regular .md page — mod.default is { html, frontmatter, headings }
115
118
  if (!mod.default) return null;
119
+
120
+ // TOM-49: Changelog page type
121
+ if (mod.isChangelog && mod.changelogEntries) {
122
+ return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
123
+ }
124
+
116
125
  return { isMdx: false, ...mod.default };
117
126
  } catch (err) {
118
127
  console.error(`Failed to load page: ${id}`, err);
@@ -160,6 +169,15 @@ function App() {
160
169
  description: r.frontmatter.description,
161
170
  }));
162
171
 
172
+ // TOM-48: Compute edit URL for current page
173
+ const currentRoute = routes.find((r: any) => r.id === currentPageId);
174
+ let editUrl: string | undefined;
175
+ if (config.editLink && currentRoute?.filePath) {
176
+ const { repo, branch = "main", dir = "" } = config.editLink;
177
+ const dirPrefix = dir ? `${dir.replace(/\/$/, "")}/` : "";
178
+ editUrl = `https://github.com/${repo}/edit/${branch}/${dirPrefix}${currentRoute.filePath}`;
179
+ }
180
+
163
181
  return (
164
182
  <>
165
183
  <style>{contentStyles}</style>
@@ -173,6 +191,10 @@ function App() {
173
191
  pageTitle={pageData?.frontmatter.title || (loading ? "Loading..." : "Not Found")}
174
192
  pageDescription={pageData?.frontmatter.description}
175
193
  headings={pageData?.headings || []}
194
+ tocEnabled={pageData?.frontmatter.toc !== false}
195
+ editUrl={editUrl}
196
+ lastUpdated={currentRoute?.lastUpdated}
197
+ changelogEntries={!pageData?.isMdx ? pageData?.changelogEntries : undefined}
176
198
  onNavigate={navigateTo}
177
199
  allPages={allPages}
178
200
  docContext={docContext}