@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.
- package/dist/chunk-BZGWSKT2.js +573 -0
- package/dist/chunk-DO544M3G.js +1702 -0
- package/dist/chunk-FWBTK5TL.js +1444 -0
- package/dist/chunk-JZRT4WNC.js +1441 -0
- package/dist/chunk-LIMYFTPC.js +1468 -0
- package/dist/chunk-MEP7P6A7.js +1500 -0
- package/dist/chunk-OEXM3BEC.js +1702 -0
- package/dist/chunk-QCWZYABW.js +1468 -0
- package/dist/chunk-RKTT3ZEX.js +1500 -0
- package/dist/chunk-UKYFJSUA.js +509 -0
- package/dist/entry.js +1 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/src/Shell.test.tsx +242 -8
- package/src/Shell.tsx +229 -18
- package/src/entry.tsx +24 -2
package/src/Shell.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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-
|
|
611
|
-
{
|
|
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
|
-
{
|
|
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
|
-
<
|
|
649
|
-
{
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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}
|