@tomehq/theme 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @tomehq/theme
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Shell: logo links back to landing page, dynamic version in sidebar footer
8
+ - Shell: edit link support, table of contents depth config, changelog page layout
9
+ - Entry: pass new config fields (editLink, tableOfContents, plugins) to Shell
10
+ - Updated dependencies
11
+ - @tomehq/core@0.2.0
12
+ - @tomehq/components@0.2.0
13
+
3
14
  ## 0.1.2
4
15
 
5
16
  ### Patch Changes
@@ -565,6 +565,26 @@ var MoonIcon = () => /* @__PURE__ */ jsx2(Icon, { d: "M21 12.79A9 9 0 1 1 11.21
565
565
  var SunIcon = () => /* @__PURE__ */ jsx2(Icon, { d: "M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10Z" });
566
566
  var ArrowLeft = () => /* @__PURE__ */ jsx2(Icon, { d: "M19 12H5M12 19l-7-7 7-7", size: 14 });
567
567
  var ArrowRight = () => /* @__PURE__ */ jsx2(Icon, { d: "M5 12h14M12 5l7 7-7 7", size: 14 });
568
+ var PencilIcon = () => /* @__PURE__ */ jsx2(Icon, { d: "M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z", size: 13 });
569
+ function formatRelativeDate(isoDate) {
570
+ const date = new Date(isoDate);
571
+ const now = /* @__PURE__ */ new Date();
572
+ const diffMs = now.getTime() - date.getTime();
573
+ if (isNaN(diffMs)) return "";
574
+ const seconds = Math.floor(diffMs / 1e3);
575
+ const minutes = Math.floor(seconds / 60);
576
+ const hours = Math.floor(minutes / 60);
577
+ const days = Math.floor(hours / 24);
578
+ const months = Math.floor(days / 30);
579
+ const years = Math.floor(days / 365);
580
+ if (seconds < 60) return "just now";
581
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
582
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
583
+ if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
584
+ if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`;
585
+ if (years >= 1) return `${years} year${years === 1 ? "" : "s"} ago`;
586
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
587
+ }
568
588
  var pagefindInstance = null;
569
589
  var PAGEFIND_PATH = "/_pagefind/pagefind.js";
570
590
  async function initPagefind() {
@@ -686,6 +706,68 @@ function AlgoliaSearchModal({
686
706
  var VersionIcon = () => /* @__PURE__ */ jsx2(Icon, { d: "M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z", size: 14 });
687
707
  var GlobeIcon = () => /* @__PURE__ */ jsx2(Icon, { d: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9 15 15 0 0 1-4-9 15 15 0 0 1 4-9Z", size: 14 });
688
708
  var ExtLinkIcon = () => /* @__PURE__ */ jsx2(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 });
709
+ var CHANGELOG_SECTION_COLORS = {
710
+ Added: "#22c55e",
711
+ Changed: "#3b82f6",
712
+ Deprecated: "#f59e0b",
713
+ Removed: "#ef4444",
714
+ Fixed: "#8b5cf6",
715
+ Security: "#f97316"
716
+ };
717
+ function ChangelogView({ entries }) {
718
+ const [showAll, setShowAll] = useState2(entries.length <= 5);
719
+ const visible = showAll ? entries : entries.slice(0, 5);
720
+ return /* @__PURE__ */ jsxs2("div", { "data-testid": "changelog-timeline", style: { position: "relative" }, children: [
721
+ /* @__PURE__ */ jsx2("div", { style: { position: "absolute", left: 15, top: 8, bottom: 8, width: 2, background: "var(--bd)" } }),
722
+ visible.map((entry, i) => /* @__PURE__ */ jsxs2(
723
+ "div",
724
+ {
725
+ "data-testid": `changelog-entry-${entry.version}`,
726
+ style: { position: "relative", paddingLeft: 44, paddingBottom: i < visible.length - 1 ? 32 : 0 },
727
+ children: [
728
+ /* @__PURE__ */ jsx2("div", { style: {
729
+ position: "absolute",
730
+ left: 8,
731
+ top: 6,
732
+ width: 16,
733
+ height: 16,
734
+ borderRadius: "50%",
735
+ background: entry.version === "Unreleased" ? "var(--txM)" : "var(--ac)",
736
+ border: "3px solid var(--bg, #1a1a1a)"
737
+ } }),
738
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", alignItems: "baseline", gap: 12, marginBottom: 12 }, children: [
739
+ /* @__PURE__ */ jsx2("span", { style: { fontSize: 18, fontWeight: 700, color: "var(--tx)", fontFamily: "var(--font-heading, inherit)" }, children: entry.url ? /* @__PURE__ */ jsx2("a", { href: entry.url, target: "_blank", rel: "noopener noreferrer", style: { color: "inherit", textDecoration: "none" }, children: entry.version }) : entry.version }),
740
+ entry.date && /* @__PURE__ */ jsx2("span", { style: { fontSize: 13, color: "var(--txM)", fontFamily: "var(--font-code, monospace)" }, children: entry.date })
741
+ ] }),
742
+ entry.sections.map((section) => {
743
+ const sColor = CHANGELOG_SECTION_COLORS[section.type] || "#6b7280";
744
+ return /* @__PURE__ */ jsxs2("div", { style: { marginBottom: 16 }, children: [
745
+ /* @__PURE__ */ jsxs2("div", { style: { display: "inline-flex", alignItems: "center", gap: 6, marginBottom: 8 }, children: [
746
+ /* @__PURE__ */ jsx2("span", { style: { display: "inline-block", width: 8, height: 8, borderRadius: "50%", background: sColor } }),
747
+ /* @__PURE__ */ jsx2("span", { style: { fontSize: 12, fontWeight: 600, textTransform: "uppercase", letterSpacing: ".06em", color: sColor, fontFamily: "var(--font-code, monospace)" }, children: section.type })
748
+ ] }),
749
+ /* @__PURE__ */ jsx2("ul", { style: { margin: 0, paddingLeft: 18, listStyleType: "disc", color: "var(--tx2)" }, children: section.items.map((item, j) => /* @__PURE__ */ jsx2("li", { style: { fontSize: 14, lineHeight: 1.7, color: "var(--tx2)", marginBottom: 2 }, children: item }, j)) })
750
+ ] }, section.type);
751
+ })
752
+ ]
753
+ },
754
+ entry.version
755
+ )),
756
+ !showAll && entries.length > 5 && /* @__PURE__ */ jsx2("div", { style: { textAlign: "center", marginTop: 24 }, children: /* @__PURE__ */ jsxs2(
757
+ "button",
758
+ {
759
+ "data-testid": "changelog-show-more",
760
+ onClick: () => setShowAll(true),
761
+ 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" },
762
+ children: [
763
+ "Show all ",
764
+ entries.length,
765
+ " releases"
766
+ ]
767
+ }
768
+ ) })
769
+ ] });
770
+ }
689
771
  function Shell({
690
772
  config: config2,
691
773
  navigation: navigation2,
@@ -696,6 +778,10 @@ function Shell({
696
778
  pageTitle,
697
779
  pageDescription,
698
780
  headings,
781
+ tocEnabled = true,
782
+ editUrl,
783
+ lastUpdated,
784
+ changelogEntries,
699
785
  onNavigate,
700
786
  allPages,
701
787
  versioning,
@@ -744,6 +830,60 @@ function Shell({
744
830
  useEffect2(() => {
745
831
  contentRef.current?.scrollTo(0, 0);
746
832
  }, [currentPageId]);
833
+ const tocConfig = config2.toc;
834
+ const tocDepth = tocConfig?.depth ?? 3;
835
+ const tocGlobalEnabled = tocConfig?.enabled !== false;
836
+ const showToc = tocGlobalEnabled && tocEnabled;
837
+ const filteredHeadings = headings.filter((h) => h.depth <= tocDepth);
838
+ const [activeHeadingId, setActiveHeadingId] = useState2("");
839
+ useEffect2(() => {
840
+ if (!showToc || filteredHeadings.length < 2) return;
841
+ const scrollRoot = contentRef.current;
842
+ if (!scrollRoot) return;
843
+ const timerId = setTimeout(() => {
844
+ const headingElements = [];
845
+ for (const h of filteredHeadings) {
846
+ const el = scrollRoot.querySelector(`#${CSS.escape(h.id)}`);
847
+ if (el) headingElements.push(el);
848
+ }
849
+ if (headingElements.length === 0) return;
850
+ const observer = new IntersectionObserver(
851
+ (entries) => {
852
+ const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
853
+ if (visible.length > 0) {
854
+ setActiveHeadingId(visible[0].target.id);
855
+ }
856
+ },
857
+ {
858
+ root: scrollRoot,
859
+ // Trigger when heading enters the top 20% of the scroll container
860
+ rootMargin: "0px 0px -80% 0px",
861
+ threshold: 0
862
+ }
863
+ );
864
+ for (const el of headingElements) observer.observe(el);
865
+ observerRef.current = observer;
866
+ }, 100);
867
+ return () => {
868
+ clearTimeout(timerId);
869
+ observerRef.current?.disconnect();
870
+ observerRef.current = null;
871
+ };
872
+ }, [currentPageId, showToc, filteredHeadings.map((h) => h.id).join(",")]);
873
+ const observerRef = useRef2(null);
874
+ useEffect2(() => {
875
+ setActiveHeadingId("");
876
+ }, [currentPageId]);
877
+ const scrollToHeading = useCallback2((e, id) => {
878
+ e.preventDefault();
879
+ const scrollRoot = contentRef.current;
880
+ if (!scrollRoot) return;
881
+ const target = scrollRoot.querySelector(`#${CSS.escape(id)}`);
882
+ if (target) {
883
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
884
+ setActiveHeadingId(id);
885
+ }
886
+ }, []);
747
887
  useEffect2(() => {
748
888
  const h = (e) => {
749
889
  if ((e.metaKey || e.ctrlKey) && e.key === "k") {
@@ -815,7 +955,7 @@ function Shell({
815
955
  transition: "width .2s, min-width .2s",
816
956
  overflow: "hidden"
817
957
  }, children: [
818
- /* @__PURE__ */ jsxs2("div", { style: { padding: "18px 20px", display: "flex", alignItems: "baseline", gap: 6, borderBottom: "1px solid var(--bd)" }, children: [
958
+ /* @__PURE__ */ jsxs2("a", { href: "/", style: { padding: "18px 20px", display: "flex", alignItems: "baseline", gap: 6, borderBottom: "1px solid var(--bd)", textDecoration: "none", color: "inherit" }, children: [
819
959
  /* @__PURE__ */ jsx2("span", { style: { fontFamily: "var(--font-heading)", fontSize: 22, fontWeight: 700, fontStyle: "italic" }, children: config2.name }),
820
960
  /* @__PURE__ */ jsx2("span", { style: { width: 5, height: 5, borderRadius: "50%", background: "var(--ac)", display: "inline-block" } })
821
961
  ] }),
@@ -1138,14 +1278,44 @@ function Shell({
1138
1278
  /* @__PURE__ */ jsxs2("main", { style: { flex: 1, maxWidth: 760, padding: "40px 48px 80px", margin: "0 auto" }, children: [
1139
1279
  /* @__PURE__ */ jsx2("h1", { style: { fontFamily: "var(--font-heading)", fontSize: 38, fontWeight: 400, fontStyle: "italic", lineHeight: 1.15, marginBottom: 8 }, children: pageTitle }),
1140
1280
  pageDescription && /* @__PURE__ */ jsx2("p", { style: { fontSize: 16, color: "var(--tx2)", lineHeight: 1.6, marginBottom: 32 }, children: pageDescription }),
1141
- /* @__PURE__ */ jsx2("div", { style: { borderTop: "1px solid var(--bd)", paddingTop: 28 }, children: PageComponent ? /* @__PURE__ */ jsx2("div", { className: "tome-content", children: /* @__PURE__ */ jsx2(PageComponent, { components: mdxComponents || {} }) }) : /* @__PURE__ */ jsx2(
1281
+ /* @__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(
1142
1282
  "div",
1143
1283
  {
1144
1284
  className: "tome-content",
1145
- dangerouslySetInnerHTML: { __html: pageHtml || "" }
1285
+ dangerouslySetInnerHTML: { __html: (pageHtml || "").replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, "") }
1146
1286
  }
1147
1287
  ) }),
1148
- /* @__PURE__ */ jsxs2("div", { style: { display: "flex", justifyContent: "space-between", marginTop: 48, paddingTop: 24, borderTop: "1px solid var(--bd)", gap: 16 }, children: [
1288
+ (editUrl || lastUpdated) && /* @__PURE__ */ jsxs2("div", { style: { marginTop: 40, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16 }, children: [
1289
+ editUrl && /* @__PURE__ */ jsx2("div", { "data-testid": "edit-page-link", children: /* @__PURE__ */ jsxs2(
1290
+ "a",
1291
+ {
1292
+ href: editUrl,
1293
+ target: "_blank",
1294
+ rel: "noopener noreferrer",
1295
+ style: {
1296
+ display: "inline-flex",
1297
+ alignItems: "center",
1298
+ gap: 6,
1299
+ color: "var(--txM)",
1300
+ textDecoration: "none",
1301
+ fontSize: 13,
1302
+ fontFamily: "var(--font-body)",
1303
+ transition: "color .15s"
1304
+ },
1305
+ onMouseOver: (e) => e.currentTarget.style.color = "var(--ac)",
1306
+ onMouseOut: (e) => e.currentTarget.style.color = "var(--txM)",
1307
+ children: [
1308
+ /* @__PURE__ */ jsx2(PencilIcon, {}),
1309
+ " Edit this page on GitHub"
1310
+ ]
1311
+ }
1312
+ ) }),
1313
+ lastUpdated && /* @__PURE__ */ jsxs2("div", { "data-testid": "last-updated", style: { fontSize: 12, color: "var(--txM)", fontFamily: "var(--font-body)" }, children: [
1314
+ "Last updated ",
1315
+ formatRelativeDate(lastUpdated)
1316
+ ] })
1317
+ ] }),
1318
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", justifyContent: "space-between", marginTop: editUrl || lastUpdated ? 16 : 48, paddingTop: 24, borderTop: "1px solid var(--bd)", gap: 16 }, children: [
1149
1319
  prev ? /* @__PURE__ */ jsxs2("button", { onClick: () => onNavigate(prev.id), style: {
1150
1320
  display: "flex",
1151
1321
  alignItems: "center",
@@ -1184,18 +1354,34 @@ function Shell({
1184
1354
  ] }) : /* @__PURE__ */ jsx2("div", {})
1185
1355
  ] })
1186
1356
  ] }),
1187
- headings.length > 0 && wide && /* @__PURE__ */ jsxs2("aside", { style: { width: 200, padding: "40px 16px 40px 0", position: "sticky", top: 0, alignSelf: "flex-start", flexShrink: 0 }, children: [
1357
+ showToc && filteredHeadings.length >= 2 && wide && /* @__PURE__ */ jsxs2("aside", { "data-testid": "toc-sidebar", style: { width: 200, padding: "40px 16px 40px 0", position: "sticky", top: 0, alignSelf: "flex-start", flexShrink: 0 }, children: [
1188
1358
  /* @__PURE__ */ jsx2("div", { style: { fontSize: 10, fontWeight: 600, textTransform: "uppercase", letterSpacing: ".1em", color: "var(--txM)", marginBottom: 12, fontFamily: "var(--font-code)" }, children: "On this page" }),
1189
- /* @__PURE__ */ jsx2("div", { style: { borderLeft: "1px solid var(--bd)", paddingLeft: 0 }, children: headings.map((h, i) => /* @__PURE__ */ jsx2("a", { href: `#${h.id}`, style: {
1190
- display: "block",
1191
- fontSize: 12,
1192
- color: "var(--txM)",
1193
- textDecoration: "none",
1194
- padding: "4px 12px",
1195
- paddingLeft: 12 + (h.depth - 2) * 12,
1196
- lineHeight: 1.4,
1197
- transition: "color .12s"
1198
- }, children: h.text }, i)) })
1359
+ /* @__PURE__ */ jsx2("nav", { "aria-label": "Table of contents", style: { borderLeft: "1px solid var(--bd)", paddingLeft: 0 }, children: filteredHeadings.map((h, i) => {
1360
+ const isActive = activeHeadingId === h.id;
1361
+ return /* @__PURE__ */ jsx2(
1362
+ "a",
1363
+ {
1364
+ href: `#${h.id}`,
1365
+ onClick: (e) => scrollToHeading(e, h.id),
1366
+ "data-testid": `toc-link-${h.id}`,
1367
+ style: {
1368
+ display: "block",
1369
+ fontSize: 12,
1370
+ color: isActive ? "var(--ac)" : "var(--txM)",
1371
+ fontWeight: isActive ? 500 : 400,
1372
+ textDecoration: "none",
1373
+ padding: "4px 12px",
1374
+ paddingLeft: 12 + (h.depth - 2) * 12,
1375
+ lineHeight: 1.4,
1376
+ transition: "color .15s, font-weight .15s",
1377
+ borderLeft: isActive ? "2px solid var(--ac)" : "2px solid transparent",
1378
+ marginLeft: -1
1379
+ },
1380
+ children: h.text
1381
+ },
1382
+ i
1383
+ );
1384
+ }) })
1199
1385
  ] })
1200
1386
  ] })
1201
1387
  ] })
@@ -1355,7 +1541,8 @@ import {
1355
1541
  Card,
1356
1542
  CardGroup,
1357
1543
  Steps,
1358
- Accordion
1544
+ Accordion,
1545
+ ChangelogTimeline
1359
1546
  } from "@tomehq/components";
1360
1547
  import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1361
1548
  var MDX_COMPONENTS = {
@@ -1364,11 +1551,13 @@ var MDX_COMPONENTS = {
1364
1551
  Card,
1365
1552
  CardGroup,
1366
1553
  Steps,
1367
- Accordion
1554
+ Accordion,
1555
+ ChangelogTimeline
1368
1556
  };
1369
1557
  var contentStyles = `
1370
1558
  @import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,700&family=Fira+Code:wght@400;500;600&display=swap');
1371
1559
 
1560
+ .tome-content h1 { display: none; }
1372
1561
  .tome-content h2 { font-family: var(--font-body); font-size: 1.35em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; display: flex; align-items: center; gap: 10px; letter-spacing: 0.01em; }
1373
1562
  .tome-content h2::before { content: "#"; font-family: var(--font-heading); font-size: 1.2em; font-weight: 300; font-style: italic; color: var(--ac); opacity: 0.5; }
1374
1563
  .tome-content h3 { font-family: var(--font-body); font-size: 1.15em; font-weight: 600; margin-top: 1.5em; margin-bottom: 0.5em; }
@@ -1424,6 +1613,9 @@ async function loadPage(id) {
1424
1613
  };
1425
1614
  }
1426
1615
  if (!mod.default) return null;
1616
+ if (mod.isChangelog && mod.changelogEntries) {
1617
+ return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
1618
+ }
1427
1619
  return { isMdx: false, ...mod.default };
1428
1620
  } catch (err) {
1429
1621
  console.error(`Failed to load page: ${id}`, err);
@@ -1464,6 +1656,13 @@ function App() {
1464
1656
  title: r.frontmatter.title,
1465
1657
  description: r.frontmatter.description
1466
1658
  }));
1659
+ const currentRoute = routes.find((r) => r.id === currentPageId);
1660
+ let editUrl;
1661
+ if (config.editLink && currentRoute?.filePath) {
1662
+ const { repo, branch = "main", dir = "" } = config.editLink;
1663
+ const dirPrefix = dir ? `${dir.replace(/\/$/, "")}/` : "";
1664
+ editUrl = `https://github.com/${repo}/edit/${branch}/${dirPrefix}${currentRoute.filePath}`;
1665
+ }
1467
1666
  return /* @__PURE__ */ jsxs3(Fragment, { children: [
1468
1667
  /* @__PURE__ */ jsx3("style", { children: contentStyles }),
1469
1668
  /* @__PURE__ */ jsx3(
@@ -1478,6 +1677,10 @@ function App() {
1478
1677
  pageTitle: pageData?.frontmatter.title || (loading ? "Loading..." : "Not Found"),
1479
1678
  pageDescription: pageData?.frontmatter.description,
1480
1679
  headings: pageData?.headings || [],
1680
+ tocEnabled: pageData?.frontmatter.toc !== false,
1681
+ editUrl,
1682
+ lastUpdated: currentRoute?.lastUpdated,
1683
+ changelogEntries: !pageData?.isMdx ? pageData?.changelogEntries : void 0,
1481
1684
  onNavigate: navigateTo,
1482
1685
  allPages,
1483
1686
  docContext
package/dist/entry.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  entry_default
3
- } from "./chunk-JA4PMX6M.js";
3
+ } from "./chunk-CTPOZMMK.js";
4
4
  export {
5
5
  entry_default as default
6
6
  };
package/dist/index.d.ts CHANGED
@@ -36,6 +36,10 @@ interface ShellProps {
36
36
  model?: string;
37
37
  apiKeyEnv?: string;
38
38
  };
39
+ toc?: {
40
+ enabled?: boolean;
41
+ depth?: number;
42
+ };
39
43
  topNav?: Array<{
40
44
  label: string;
41
45
  href: string;
@@ -64,6 +68,18 @@ interface ShellProps {
64
68
  text: string;
65
69
  id: string;
66
70
  }>;
71
+ tocEnabled?: boolean;
72
+ editUrl?: string;
73
+ lastUpdated?: string;
74
+ changelogEntries?: Array<{
75
+ version: string;
76
+ date?: string;
77
+ url?: string;
78
+ sections: Array<{
79
+ type: string;
80
+ items: string[];
81
+ }>;
82
+ }>;
67
83
  onNavigate: (id: string) => void;
68
84
  allPages: Array<{
69
85
  id: string;
@@ -80,7 +96,7 @@ interface ShellProps {
80
96
  content: string;
81
97
  }>;
82
98
  }
83
- declare function Shell({ config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents, pageTitle, pageDescription, headings, onNavigate, allPages, versioning, currentVersion, i18n, currentLocale, docContext, }: ShellProps): react_jsx_runtime.JSX.Element;
99
+ declare function Shell({ config, navigation, currentPageId, pageHtml, pageComponent, mdxComponents, pageTitle, pageDescription, headings, tocEnabled, editUrl, lastUpdated, changelogEntries, onNavigate, allPages, versioning, currentVersion, i18n, currentLocale, docContext, }: ShellProps): react_jsx_runtime.JSX.Element;
84
100
 
85
101
  interface AiChatProps {
86
102
  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-JA4PMX6M.js";
6
+ } from "./chunk-CTPOZMMK.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.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Tome default theme and React app shell",
5
5
  "type": "module",
6
6
  "main": "./src/index.tsx",
@@ -9,8 +9,8 @@
9
9
  "./entry": "./src/entry.tsx"
10
10
  },
11
11
  "dependencies": {
12
- "@tomehq/components": "0.1.1",
13
- "@tomehq/core": "0.1.1"
12
+ "@tomehq/components": "0.2.1",
13
+ "@tomehq/core": "0.2.1"
14
14
  },
15
15
  "peerDependencies": {
16
16
  "react": "^18.0.0 || ^19.0.0",
@@ -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,21 +771,52 @@ 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>
615
781
  ) : (
616
782
  <div
617
783
  className="tome-content"
618
- dangerouslySetInnerHTML={{ __html: pageHtml || "" }}
784
+ dangerouslySetInnerHTML={{ __html: (pageHtml || "").replace(/<h1[^>]*>[\s\S]*?<\/h1>\s*/, "") }}
619
785
  />
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,12 +30,14 @@ 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 ───────────────────────────────────────
35
37
  const contentStyles = `
36
38
  @import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@300;400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,700&family=Fira+Code:wght@400;500;600&display=swap');
37
39
 
40
+ .tome-content h1 { display: none; }
38
41
  .tome-content h2 { font-family: var(--font-body); font-size: 1.35em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; display: flex; align-items: center; gap: 10px; letter-spacing: 0.01em; }
39
42
  .tome-content h2::before { content: "#"; font-family: var(--font-heading); font-size: 1.2em; font-weight: 300; font-style: italic; color: var(--ac); opacity: 0.5; }
40
43
  .tome-content h3 { font-family: var(--font-body); font-size: 1.15em; font-weight: 600; margin-top: 1.5em; margin-bottom: 0.5em; }
@@ -82,14 +85,15 @@ const contentStyles = `
82
85
  interface HtmlPage {
83
86
  isMdx: false;
84
87
  html: string;
85
- frontmatter: { title: string; description?: string };
88
+ frontmatter: { title: string; description?: string; toc?: boolean; type?: string };
86
89
  headings: Array<{ depth: number; text: string; id: string }>;
90
+ changelogEntries?: Array<{ version: string; date?: string; url?: string; sections: Array<{ type: string; items: string[] }> }>;
87
91
  }
88
92
 
89
93
  interface MdxPage {
90
94
  isMdx: true;
91
95
  component: React.ComponentType<{ components?: Record<string, React.ComponentType> }>;
92
- frontmatter: { title: string; description?: string };
96
+ frontmatter: { title: string; description?: string; toc?: boolean; type?: string };
93
97
  headings: Array<{ depth: number; text: string; id: string }>;
94
98
  }
95
99
 
@@ -113,6 +117,12 @@ async function loadPage(id: string): Promise<LoadedPage | null> {
113
117
 
114
118
  // Regular .md page — mod.default is { html, frontmatter, headings }
115
119
  if (!mod.default) return null;
120
+
121
+ // TOM-49: Changelog page type
122
+ if (mod.isChangelog && mod.changelogEntries) {
123
+ return { isMdx: false, ...mod.default, changelogEntries: mod.changelogEntries };
124
+ }
125
+
116
126
  return { isMdx: false, ...mod.default };
117
127
  } catch (err) {
118
128
  console.error(`Failed to load page: ${id}`, err);
@@ -160,6 +170,15 @@ function App() {
160
170
  description: r.frontmatter.description,
161
171
  }));
162
172
 
173
+ // TOM-48: Compute edit URL for current page
174
+ const currentRoute = routes.find((r: any) => r.id === currentPageId);
175
+ let editUrl: string | undefined;
176
+ if (config.editLink && currentRoute?.filePath) {
177
+ const { repo, branch = "main", dir = "" } = config.editLink;
178
+ const dirPrefix = dir ? `${dir.replace(/\/$/, "")}/` : "";
179
+ editUrl = `https://github.com/${repo}/edit/${branch}/${dirPrefix}${currentRoute.filePath}`;
180
+ }
181
+
163
182
  return (
164
183
  <>
165
184
  <style>{contentStyles}</style>
@@ -173,6 +192,10 @@ function App() {
173
192
  pageTitle={pageData?.frontmatter.title || (loading ? "Loading..." : "Not Found")}
174
193
  pageDescription={pageData?.frontmatter.description}
175
194
  headings={pageData?.headings || []}
195
+ tocEnabled={pageData?.frontmatter.toc !== false}
196
+ editUrl={editUrl}
197
+ lastUpdated={currentRoute?.lastUpdated}
198
+ changelogEntries={!pageData?.isMdx ? pageData?.changelogEntries : undefined}
176
199
  onNavigate={navigateTo}
177
200
  allPages={allPages}
178
201
  docContext={docContext}