@carlonicora/nextjs-jsonapi 3.12.0 → 3.13.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.
Files changed (143) hide show
  1. package/dist/{AssistantInterface-DwdBzS9f.d.ts → AssistantInterface-RvZzlcA_.d.ts} +2 -0
  2. package/dist/{AssistantInterface-DMh-zApr.d.mts → AssistantInterface-jL56dyNc.d.mts} +2 -0
  3. package/dist/{AssistantMessageInterface-0zyVingf.d.ts → AssistantMessageInterface-CuaMDs2i.d.ts} +1 -1
  4. package/dist/{AssistantMessageInterface-ddyG114R.d.mts → AssistantMessageInterface-Du3ShThp.d.mts} +1 -1
  5. package/dist/{BlockNoteEditor-6G27S6P3.js → BlockNoteEditor-CFVSHS46.js} +19 -19
  6. package/dist/{BlockNoteEditor-6G27S6P3.js.map → BlockNoteEditor-CFVSHS46.js.map} +1 -1
  7. package/dist/{BlockNoteEditor-CR5KH4AI.mjs → BlockNoteEditor-SERY337S.mjs} +4 -4
  8. package/dist/{BlockNoteViewer-5MITJ7AD.js → BlockNoteViewer-CWFPYYIU.js} +3 -3
  9. package/dist/{BlockNoteViewer-5MITJ7AD.js.map → BlockNoteViewer-CWFPYYIU.js.map} +1 -1
  10. package/dist/{BlockNoteViewer-NJKFUNXP.mjs → BlockNoteViewer-P2VKEFRN.mjs} +2 -2
  11. package/dist/{ai-connection.module-28f-hux0.d.mts → HandbookSectionInterface-BOxIdBdd.d.mts} +54 -1
  12. package/dist/{ai-connection.module-Bqwh_DnR.d.ts → HandbookSectionInterface-CAXxbDw0.d.ts} +54 -1
  13. package/dist/{auth.interface-B6ZQLGjS.d.ts → auth.interface-BC1jp_ni.d.ts} +1 -1
  14. package/dist/{auth.interface-jYD8Gc4P.d.mts → auth.interface-dR2-6gPM.d.mts} +1 -1
  15. package/dist/billing/index.js +401 -401
  16. package/dist/billing/index.mjs +3 -3
  17. package/dist/{chunk-L7DZSTFU.js → chunk-IB7DTXWG.js} +846 -129
  18. package/dist/chunk-IB7DTXWG.js.map +1 -0
  19. package/dist/{chunk-OKGGYCO5.js → chunk-LVE3BJ6K.js} +2088 -1134
  20. package/dist/chunk-LVE3BJ6K.js.map +1 -0
  21. package/dist/{chunk-Q2BREXH3.mjs → chunk-OSFYCSTW.mjs} +724 -7
  22. package/dist/chunk-OSFYCSTW.mjs.map +1 -0
  23. package/dist/{chunk-JEGL7KHK.mjs → chunk-UQXE3SME.mjs} +2081 -1127
  24. package/dist/chunk-UQXE3SME.mjs.map +1 -0
  25. package/dist/{chunk-HVNEWDRF.mjs → chunk-VZKJIAND.mjs} +2 -2
  26. package/dist/{chunk-4ETVCRQD.js → chunk-XH4IURUD.js} +7 -7
  27. package/dist/{chunk-4ETVCRQD.js.map → chunk-XH4IURUD.js.map} +1 -1
  28. package/dist/client/index.d.mts +2 -2
  29. package/dist/client/index.d.ts +2 -2
  30. package/dist/client/index.js +4 -4
  31. package/dist/client/index.mjs +3 -3
  32. package/dist/components/index.d.mts +258 -7
  33. package/dist/components/index.d.ts +258 -7
  34. package/dist/components/index.js +30 -4
  35. package/dist/components/index.js.map +1 -1
  36. package/dist/components/index.mjs +29 -3
  37. package/dist/contexts/index.d.mts +17 -5
  38. package/dist/contexts/index.d.ts +17 -5
  39. package/dist/contexts/index.js +4 -4
  40. package/dist/contexts/index.mjs +3 -3
  41. package/dist/core/index.d.mts +491 -11
  42. package/dist/core/index.d.ts +491 -11
  43. package/dist/core/index.js +38 -2
  44. package/dist/core/index.js.map +1 -1
  45. package/dist/core/index.mjs +37 -1
  46. package/dist/features/help/index.js +37 -37
  47. package/dist/features/help/index.mjs +3 -3
  48. package/dist/features/tokenusage/index.js +101 -101
  49. package/dist/features/tokenusage/index.mjs +3 -3
  50. package/dist/index.d.mts +7 -7
  51. package/dist/index.d.ts +7 -7
  52. package/dist/index.js +39 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/index.mjs +38 -2
  55. package/dist/{notification.interface-DYF56CtE.d.ts → notification.interface-CjzV_KII.d.ts} +1 -1
  56. package/dist/{notification.interface-BWOCvKRP.d.mts → notification.interface-MnRt1Tjm.d.mts} +1 -1
  57. package/dist/{s3.service-D_dswjgz.d.mts → s3.service-C0Yq2Sx3.d.mts} +2 -2
  58. package/dist/{s3.service-DserU9VK.d.ts → s3.service-CUGarZs3.d.ts} +2 -2
  59. package/dist/server/index.d.mts +3 -3
  60. package/dist/server/index.d.ts +3 -3
  61. package/dist/server/index.js +3 -3
  62. package/dist/server/index.mjs +1 -1
  63. package/dist/{useSocket-BZG9oX-L.d.mts → useSocket-BcH7l7B8.d.mts} +1 -1
  64. package/dist/{useSocket-CiyTtYFj.d.ts → useSocket-DCZd_eJk.d.ts} +1 -1
  65. package/package.json +1 -1
  66. package/src/components/containers/ReactMarkdownContainer.tsx +61 -11
  67. package/src/components/index.ts +1 -0
  68. package/src/core/index.ts +5 -0
  69. package/src/core/registry/ModuleRegistry.ts +4 -0
  70. package/src/features/assistant/components/parts/AssistantComposer.tsx +22 -3
  71. package/src/features/assistant/components/parts/AssistantSidebar.tsx +13 -3
  72. package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +10 -3
  73. package/src/features/assistant/contexts/AssistantContext.tsx +30 -3
  74. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +32 -0
  75. package/src/features/assistant/data/Assistant.ts +2 -0
  76. package/src/features/assistant/data/AssistantInterface.ts +2 -0
  77. package/src/features/assistant/data/AssistantService.ts +4 -0
  78. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +5 -1
  79. package/src/features/assistant-message/data/AssistantMessage.ts +27 -2
  80. package/src/features/content/data/content.interface.ts +1 -1
  81. package/src/features/handbook/HandbookPageModule.ts +41 -0
  82. package/src/features/handbook/HandbookSectionModule.ts +35 -0
  83. package/src/features/handbook/HandbookThreadMessageModule.ts +21 -0
  84. package/src/features/handbook/HandbookThreadModule.ts +29 -0
  85. package/src/features/handbook/components/containers/HandbookAdminContainer.tsx +17 -0
  86. package/src/features/handbook/components/containers/HandbookAskContainer.tsx +142 -0
  87. package/src/features/handbook/components/containers/HandbookAskSheet.tsx +149 -0
  88. package/src/features/handbook/components/containers/HandbookPageContainer.tsx +143 -0
  89. package/src/features/handbook/components/containers/__tests__/HandbookAskContainer.spec.tsx +268 -0
  90. package/src/features/handbook/components/containers/__tests__/HandbookAskSheet.spec.tsx +143 -0
  91. package/src/features/handbook/components/containers/__tests__/HandbookPageContainer.spec.tsx +288 -0
  92. package/src/features/handbook/components/details/HandbookPageDetails.tsx +27 -0
  93. package/src/features/handbook/components/details/HandbookPageNeighbours.tsx +72 -0
  94. package/src/features/handbook/components/details/HandbookPageToc.tsx +165 -0
  95. package/src/features/handbook/components/forms/HandbookSyncButton.tsx +43 -0
  96. package/src/features/handbook/components/forms/__tests__/HandbookSyncButton.spec.tsx +42 -0
  97. package/src/features/handbook/components/index.ts +12 -0
  98. package/src/features/handbook/components/lists/HandbookContents.tsx +196 -0
  99. package/src/features/handbook/components/lists/HandbookSectionGroup.tsx +153 -0
  100. package/src/features/handbook/components/lists/__tests__/HandbookContents.spec.tsx +247 -0
  101. package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +62 -0
  102. package/src/features/handbook/components/parts/HandbookPageNavigator.tsx +143 -0
  103. package/src/features/handbook/contexts/HandbookContext.tsx +109 -0
  104. package/src/features/handbook/contexts/__tests__/HandbookContext.spec.tsx +131 -0
  105. package/src/features/handbook/data/HandbookPage.spec.ts +136 -0
  106. package/src/features/handbook/data/HandbookPage.ts +99 -0
  107. package/src/features/handbook/data/HandbookPageFields.ts +31 -0
  108. package/src/features/handbook/data/HandbookPageInput.ts +8 -0
  109. package/src/features/handbook/data/HandbookPageInterface.ts +29 -0
  110. package/src/features/handbook/data/HandbookPageService.ts +60 -0
  111. package/src/features/handbook/data/HandbookSection.spec.ts +58 -0
  112. package/src/features/handbook/data/HandbookSection.ts +73 -0
  113. package/src/features/handbook/data/HandbookSectionInput.ts +12 -0
  114. package/src/features/handbook/data/HandbookSectionInterface.ts +26 -0
  115. package/src/features/handbook/data/HandbookSectionService.ts +26 -0
  116. package/src/features/handbook/data/HandbookThread.ts +114 -0
  117. package/src/features/handbook/data/HandbookThreadInterface.ts +31 -0
  118. package/src/features/handbook/data/HandbookThreadMessage.ts +144 -0
  119. package/src/features/handbook/data/HandbookThreadMessageInterface.ts +35 -0
  120. package/src/features/handbook/data/HandbookThreadMessageService.ts +30 -0
  121. package/src/features/handbook/data/HandbookThreadService.ts +86 -0
  122. package/src/features/handbook/data/__tests__/HandbookThreadService.spec.ts +112 -0
  123. package/src/features/handbook/data/handbookDisplay.spec.ts +92 -0
  124. package/src/features/handbook/data/handbookDisplay.ts +55 -0
  125. package/src/features/handbook/data/index.ts +16 -0
  126. package/src/features/handbook/hooks/__tests__/useHandbookAsk.spec.ts +198 -0
  127. package/src/features/handbook/hooks/useHandbookAsk.ts +213 -0
  128. package/src/features/handbook/i18n-keys.ts +46 -0
  129. package/src/features/handbook/index.ts +7 -0
  130. package/src/features/how-to/components/forms/HowToEditor.tsx +0 -1
  131. package/src/features/how-to/components/forms/HowToReindexButton.spec.tsx +84 -0
  132. package/src/features/how-to/components/forms/HowToReindexButton.tsx +46 -0
  133. package/src/features/how-to/components/index.ts +1 -0
  134. package/src/features/how-to/components/lists/HowToList.tsx +5 -1
  135. package/src/features/how-to/data/HowTo.spec.ts +61 -1
  136. package/src/features/how-to/data/HowToService.ts +13 -0
  137. package/dist/chunk-JEGL7KHK.mjs.map +0 -1
  138. package/dist/chunk-L7DZSTFU.js.map +0 -1
  139. package/dist/chunk-OKGGYCO5.js.map +0 -1
  140. package/dist/chunk-Q2BREXH3.mjs.map +0 -1
  141. /package/dist/{BlockNoteEditor-CR5KH4AI.mjs.map → BlockNoteEditor-SERY337S.mjs.map} +0 -0
  142. /package/dist/{BlockNoteViewer-NJKFUNXP.mjs.map → BlockNoteViewer-P2VKEFRN.mjs.map} +0 -0
  143. /package/dist/{chunk-HVNEWDRF.mjs.map → chunk-VZKJIAND.mjs.map} +0 -0
@@ -0,0 +1,165 @@
1
+ "use client";
2
+
3
+ import { List } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useCallback, useEffect, useState, type MouseEvent as ReactMouseEvent } from "react";
6
+
7
+ import { MicroLabel } from "../../../../components";
8
+ import { cn } from "../../../../utils/cn";
9
+
10
+ type HandbookPageTocProps = {
11
+ /** The `id` of the element holding the rendered markdown. */
12
+ containerId: string;
13
+ };
14
+
15
+ type TocHeading = {
16
+ id: string;
17
+ text: string;
18
+ level: number;
19
+ };
20
+
21
+ /**
22
+ * The heading index of the page being read.
23
+ *
24
+ * Follows `HowToTableOfContents` (apps/web how-to detail): the same
25
+ * IntersectionObserver tracking, the same rootMargin, the same
26
+ * topmost-visible-wins rule and the same smooth scroll on click. Only the
27
+ * heading SOURCE differs — the how-to reads BlockNote blocks and assigns the
28
+ * ids itself, this one reads the ids `rehype-slug` already put on the rendered
29
+ * markdown, so the entries are anchors with a real `href` rather than buttons.
30
+ */
31
+ export function HandbookPageToc({ containerId }: HandbookPageTocProps) {
32
+ const t = useTranslations();
33
+ const [activeId, setActiveId] = useState<string | null>(null);
34
+ const [headings, setHeadings] = useState<TocHeading[]>([]);
35
+ const [headingsWithIds, setHeadingsWithIds] = useState<Map<string, Element>>(new Map());
36
+
37
+ // Read the headings out of the rendered markdown, and re-read them whenever
38
+ // the container's content changes — a different page renders into the same
39
+ // container without this component ever unmounting.
40
+ useEffect(() => {
41
+ const contentPanel = document.getElementById(containerId);
42
+ if (!contentPanel) return;
43
+
44
+ const collect = () => {
45
+ const headingElements = contentPanel.querySelectorAll("h1, h2, h3");
46
+ const nextHeadings: TocHeading[] = [];
47
+ const nextMap = new Map<string, Element>();
48
+
49
+ headingElements.forEach((el) => {
50
+ if (!el.id) return;
51
+ nextHeadings.push({
52
+ id: el.id,
53
+ text: el.textContent ?? "",
54
+ level: Number(el.tagName.slice(1)),
55
+ });
56
+ nextMap.set(el.id, el);
57
+ });
58
+
59
+ setHeadings(nextHeadings);
60
+ setHeadingsWithIds(nextMap);
61
+ };
62
+
63
+ collect();
64
+
65
+ const observer = new MutationObserver(collect);
66
+ observer.observe(contentPanel, { childList: true, subtree: true });
67
+
68
+ return () => observer.disconnect();
69
+ }, [containerId]);
70
+
71
+ // Set up IntersectionObserver for scroll tracking
72
+ useEffect(() => {
73
+ if (headingsWithIds.size === 0) return;
74
+
75
+ /*
76
+ `root: null` — the VIEWPORT, deliberately.
77
+
78
+ The previous version looked for a `.overflow-y-auto` descendant of the
79
+ content panel and used that as the root. There is none: the element that
80
+ actually scrolls is an ANCESTOR (the page container's scroll body), so the
81
+ root fell back to the content panel itself, which never moves relative to
82
+ its own children. The observer therefore fired once on mount and never
83
+ again, and the only thing that ever changed the highlight was a click.
84
+
85
+ Viewport-relative intersection needs no knowledge of which ancestor
86
+ scrolls — the headings move through the viewport either way.
87
+ */
88
+ const observer = new IntersectionObserver(
89
+ (entries) => {
90
+ // Find the topmost visible heading
91
+ const visibleEntries = entries.filter((entry) => entry.isIntersecting);
92
+
93
+ if (visibleEntries.length > 0) {
94
+ // Sort by their position in the document and pick the first
95
+ const topEntry = visibleEntries.reduce((top, entry) => {
96
+ const topRect = top.boundingClientRect;
97
+ const entryRect = entry.boundingClientRect;
98
+ return entryRect.top < topRect.top ? entry : top;
99
+ });
100
+ setActiveId(topEntry.target.id);
101
+ }
102
+ },
103
+ {
104
+ root: null,
105
+ // The active band is the top tenth to top third of the viewport: a
106
+ // heading is "where you are" once it has reached reading position, not
107
+ // when it is still at the bottom edge.
108
+ rootMargin: "-10% 0px -70% 0px",
109
+ threshold: 0,
110
+ },
111
+ );
112
+
113
+ headingsWithIds.forEach((element) => {
114
+ observer.observe(element);
115
+ });
116
+
117
+ return () => observer.disconnect();
118
+ }, [containerId, headingsWithIds]);
119
+
120
+ const handleClick = useCallback(
121
+ (event: ReactMouseEvent<HTMLAnchorElement>, id: string) => {
122
+ const element = headingsWithIds.get(id);
123
+ if (!element) return;
124
+
125
+ event.preventDefault();
126
+ element.scrollIntoView({ behavior: "smooth", block: "start" });
127
+ // Deliberately NOT setActiveId(id). The highlight answers "where am I",
128
+ // and the observer answers that as the scroll lands. Setting it here made
129
+ // it answer "where did I click" instead, and it stuck there.
130
+ },
131
+ [headingsWithIds],
132
+ );
133
+
134
+ // Don't render if no headings
135
+ if (headings.length === 0) return null;
136
+
137
+ return (
138
+ <div className="flex flex-col gap-y-2">
139
+ <div className="flex items-center gap-2">
140
+ <List className="text-muted-foreground h-4 w-4" />
141
+ <MicroLabel>{t("handbook.reader.onThisPage")}</MicroLabel>
142
+ </div>
143
+ <nav aria-label={t("handbook.reader.onThisPage")} className="flex flex-col gap-y-0.5">
144
+ {headings.map((heading) => (
145
+ <a
146
+ key={heading.id}
147
+ href={`#${heading.id}`}
148
+ onClick={(event) => handleClick(event, heading.id)}
149
+ className={cn(
150
+ "hover:text-primary border-s-2 py-1 text-start text-xs transition-colors",
151
+ heading.level === 1 && "ps-3",
152
+ heading.level === 2 && "ps-5",
153
+ heading.level === 3 && "ps-7",
154
+ activeId === heading.id
155
+ ? "border-primary text-primary font-medium"
156
+ : "text-muted-foreground border-transparent",
157
+ )}
158
+ >
159
+ {heading.text}
160
+ </a>
161
+ ))}
162
+ </nav>
163
+ </div>
164
+ );
165
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useState } from "react";
5
+
6
+ import { Button } from "../../../../shadcnui";
7
+ import { showError, showToast } from "../../../../utils/toast";
8
+ import { HandbookPageService } from "../../data/HandbookPageService";
9
+
10
+ type HandbookSyncButtonProps = {
11
+ refresh: () => Promise<void>;
12
+ };
13
+
14
+ /**
15
+ * The sync control is a plain Button, not a trigger: it opens nothing, so the
16
+ * render-prop composition rule for Base UI triggers does not apply here.
17
+ */
18
+ export default function HandbookSyncButton({ refresh }: HandbookSyncButtonProps) {
19
+ const t = useTranslations();
20
+ const [isSyncing, setIsSyncing] = useState(false);
21
+
22
+ const onSync = async () => {
23
+ setIsSyncing(true);
24
+ try {
25
+ await HandbookPageService.sync();
26
+ await refresh();
27
+ showToast(t(`handbook.synced`));
28
+ } catch {
29
+ // The most common failure is a consumer with no `handbook.path`
30
+ // configured, which the API answers with 400. Surface it and leave the
31
+ // list untouched.
32
+ showError(t(`handbook.syncFailed`));
33
+ } finally {
34
+ setIsSyncing(false);
35
+ }
36
+ };
37
+
38
+ return (
39
+ <Button onClick={onSync} disabled={isSyncing}>
40
+ {isSyncing ? t(`handbook.syncing`) : t(`handbook.sync`)}
41
+ </Button>
42
+ );
43
+ }
@@ -0,0 +1,42 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { HandbookPageService } from "../../../data/HandbookPageService";
6
+ import HandbookSyncButton from "../HandbookSyncButton";
7
+
8
+ vi.mock("../../../data/HandbookPageService", () => ({
9
+ HandbookPageService: { sync: vi.fn() },
10
+ }));
11
+
12
+ vi.mock("next-intl", () => ({
13
+ useTranslations: () => (key: string) => key,
14
+ }));
15
+
16
+ describe("HandbookSyncButton", () => {
17
+ beforeEach(() => {
18
+ vi.mocked(HandbookPageService.sync).mockReset().mockResolvedValue(undefined);
19
+ });
20
+
21
+ it("syncs, then refreshes the list", async () => {
22
+ const refresh = vi.fn().mockResolvedValue(undefined);
23
+ render(<HandbookSyncButton refresh={refresh} />);
24
+
25
+ await userEvent.click(screen.getByRole("button"));
26
+
27
+ await waitFor(() => expect(HandbookPageService.sync).toHaveBeenCalledTimes(1));
28
+ await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
29
+ });
30
+
31
+ it("does not refresh when the sync fails, and re-enables itself", async () => {
32
+ vi.mocked(HandbookPageService.sync).mockRejectedValue(new Error("no handbook path configured"));
33
+ const refresh = vi.fn().mockResolvedValue(undefined);
34
+ render(<HandbookSyncButton refresh={refresh} />);
35
+
36
+ await userEvent.click(screen.getByRole("button"));
37
+
38
+ await waitFor(() => expect(HandbookPageService.sync).toHaveBeenCalledTimes(1));
39
+ expect(refresh).not.toHaveBeenCalled();
40
+ await waitFor(() => expect(screen.getByRole("button")).not.toBeDisabled());
41
+ });
42
+ });
@@ -0,0 +1,12 @@
1
+ export { default as HandbookAdminContainer } from "./containers/HandbookAdminContainer";
2
+ export { default as HandbookPageContainer } from "./containers/HandbookPageContainer";
3
+ export * from "./containers/HandbookAskContainer";
4
+ export * from "./containers/HandbookAskSheet";
5
+ export * from "./details/HandbookPageDetails";
6
+ export * from "./details/HandbookPageNeighbours";
7
+ export * from "./details/HandbookPageToc";
8
+ export * from "./parts/HandbookPageNavigator";
9
+ export { default as HandbookSyncButton } from "./forms/HandbookSyncButton";
10
+ export { default as HandbookContents } from "./lists/HandbookContents";
11
+ export * from "./lists/HandbookSectionGroup";
12
+ export * from "./parts/HandbookIndexStatus";
@@ -0,0 +1,196 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useCallback, useEffect, useMemo, useState } from "react";
5
+
6
+ import { RoundPageContainer } from "../../../../components/containers/RoundPageContainer";
7
+ import { Modules } from "../../../../core";
8
+ import { HandbookProvider } from "../../contexts/HandbookContext";
9
+ import { handbookSectionSummary, handbookSectionTitle } from "../../data/handbookDisplay";
10
+ import { HandbookPageInterface } from "../../data/HandbookPageInterface";
11
+ import { HandbookPageService } from "../../data/HandbookPageService";
12
+ import { HandbookSectionInterface } from "../../data/HandbookSectionInterface";
13
+ import { HandbookSectionService } from "../../data/HandbookSectionService";
14
+ import { HandbookAskSheet } from "../containers/HandbookAskSheet";
15
+ import HandbookSyncButton from "../forms/HandbookSyncButton";
16
+ import { HandbookIndexStatus } from "../parts/HandbookIndexStatus";
17
+ import { HandbookSectionGroup } from "./HandbookSectionGroup";
18
+
19
+ type RenderedSection = {
20
+ key: string;
21
+ title: string;
22
+ summary?: string;
23
+ pages: HandbookPageInterface[];
24
+ };
25
+
26
+ /**
27
+ * The contents surface: the manual's table of contents, replacing the four
28
+ * column table of indexed files.
29
+ *
30
+ * It owns `RoundPageContainer` rather than being handed one, because
31
+ * `RoundPageContainerTitle` reads its heading from `useSharedContext()` and the
32
+ * provider that supplies it therefore has to be an ANCESTOR of the container —
33
+ * exactly the composition `HandbookAskContainer` already uses. See the
34
+ * hand-off note: the plan put the provider inside the container, which throws.
35
+ */
36
+ export default function HandbookContents() {
37
+ const t = useTranslations();
38
+
39
+ const [pages, setPages] = useState<HandbookPageInterface[]>([]);
40
+ const [sections, setSections] = useState<HandbookSectionInterface[]>([]);
41
+ const [isLoading, setIsLoading] = useState(true);
42
+ const [reloadToken, setReloadToken] = useState(0);
43
+
44
+ useEffect(() => {
45
+ let cancelled = false;
46
+
47
+ const load = async () => {
48
+ setIsLoading(true);
49
+
50
+ // The two loads are INDEPENDENT on purpose. A failed section read must
51
+ // not take the pages down with it: without sections the contents page
52
+ // falls back to the raw section keys, which is exactly what an nja
53
+ // application with no README index renders anyway.
54
+ const loadedPages = HandbookPageService.findMany({ fetchAll: true })
55
+ .then((result) => {
56
+ if (!cancelled) setPages(result ?? []);
57
+ })
58
+ .catch(() => {
59
+ if (!cancelled) setPages([]);
60
+ });
61
+
62
+ const loadedSections = HandbookSectionService.findMany()
63
+ .then((result) => {
64
+ if (!cancelled) setSections(result ?? []);
65
+ })
66
+ .catch(() => {
67
+ if (!cancelled) setSections([]);
68
+ });
69
+
70
+ await Promise.allSettled([loadedPages, loadedSections]);
71
+ if (!cancelled) setIsLoading(false);
72
+ };
73
+
74
+ void load();
75
+
76
+ return () => {
77
+ cancelled = true;
78
+ };
79
+ }, [reloadToken]);
80
+
81
+ /** `HandbookSyncButton` awaits this before it toasts; re-running the effect refetches both resources. */
82
+ const refresh = useCallback(async () => {
83
+ setReloadToken((token) => token + 1);
84
+ }, []);
85
+
86
+ const renderedSections = useMemo(
87
+ () => buildSections({ pages, sections, untitled: t("handbook.sections.untitled") }),
88
+ [pages, sections, t],
89
+ );
90
+
91
+ return (
92
+ <HandbookProvider
93
+ functions={
94
+ <>
95
+ <HandbookAskSheet />
96
+ <HandbookSyncButton refresh={refresh} />
97
+ <HandbookIndexStatus pages={pages} />
98
+ </>
99
+ }
100
+ >
101
+ {/*
102
+ NOT `fullWidth`. That flag strips the container's `mx-auto max-w-6xl`
103
+ and its padding (RoundPageContainer.tsx:477-484) — it exists for the
104
+ edge-to-edge table this page replaced. A contents page is prose: it
105
+ wants the same centred, padded column every other non-table page in the
106
+ app uses, and a summary line running to 1900px is unreadable.
107
+ */}
108
+ <RoundPageContainer module={Modules.HandbookPage} forceHeader>
109
+ {isLoading ? null : renderedSections.length === 0 ? (
110
+ <span className="text-muted-foreground text-sm">{t("handbook.empty")}</span>
111
+ ) : (
112
+ <div className="flex w-full flex-col gap-10">
113
+ {renderedSections.map((section) => (
114
+ <HandbookSectionGroup
115
+ key={section.key}
116
+ title={section.title}
117
+ summary={section.summary}
118
+ pages={section.pages}
119
+ />
120
+ ))}
121
+ </div>
122
+ )}
123
+ </RoundPageContainer>
124
+ </HandbookProvider>
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Turns the two loaded lists into the sections the page renders, in order.
130
+ *
131
+ * Three degradations, all of them load-bearing:
132
+ *
133
+ * 1. No sections loaded — the endpoint failed, or the application has no
134
+ * README index — falls back to the distinct `section` values of the pages,
135
+ * sorted, with the raw key as the heading and no blurb.
136
+ * 2. A section declared by the index but matching no page renders with its
137
+ * blurb and an empty list. Visible drift beats a silently dropped heading.
138
+ * 3. A page whose `section` is empty or matches no loaded section lands in a
139
+ * trailing group. A file at the root of the tree is one of those.
140
+ *
141
+ * The grouping and both sorts read `section`, `order` and `path`, which stay
142
+ * English: the manual is indexed in English and only shown translated. The two
143
+ * strings a section actually prints go through the display helpers, so a
144
+ * translated section renders translated and an untranslated one renders the
145
+ * English original it was indexed under.
146
+ */
147
+ function buildSections(params: {
148
+ pages: HandbookPageInterface[];
149
+ sections: HandbookSectionInterface[];
150
+ untitled: string;
151
+ }): RenderedSection[] {
152
+ const byOrder = (a: string, b: string) => a.localeCompare(b);
153
+
154
+ const ordered: { key: string; title: string; summary?: string }[] =
155
+ params.sections.length > 0
156
+ ? [...params.sections]
157
+ .sort((a, b) => byOrder(a.order || a.key, b.order || b.key))
158
+ .map((section) => ({
159
+ key: section.key,
160
+ title: handbookSectionTitle(section) || section.key,
161
+ summary: handbookSectionSummary(section),
162
+ }))
163
+ : Array.from(new Set(params.pages.map((page) => page.section).filter((key) => !!key)))
164
+ .sort(byOrder)
165
+ .map((key) => ({ key, title: key }));
166
+
167
+ const known = new Set(ordered.map((section) => section.key));
168
+
169
+ const grouped = new Map<string, HandbookPageInterface[]>();
170
+ const leftovers: HandbookPageInterface[] = [];
171
+
172
+ for (const page of params.pages) {
173
+ const key = page.section;
174
+ if (!key || !known.has(key)) {
175
+ leftovers.push(page);
176
+ continue;
177
+ }
178
+
179
+ const existing = grouped.get(key);
180
+ if (existing) existing.push(page);
181
+ else grouped.set(key, [page]);
182
+ }
183
+
184
+ const sortPages = (pages: HandbookPageInterface[]) =>
185
+ [...pages].sort((a, b) => byOrder(a.order || a.path || a.title, b.order || b.path || b.title));
186
+
187
+ const response: RenderedSection[] = ordered.map((section) => ({
188
+ ...section,
189
+ pages: sortPages(grouped.get(section.key) ?? []),
190
+ }));
191
+
192
+ if (leftovers.length > 0)
193
+ response.push({ key: "", title: params.untitled, summary: undefined, pages: sortPages(leftovers) });
194
+
195
+ return response;
196
+ }
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+
5
+ import { MicroLabel, SectionHeader } from "../../../../components/typography";
6
+ import { Modules } from "../../../../core";
7
+ import { usePageUrlGenerator } from "../../../../hooks";
8
+ import { Link } from "../../../../shadcnui";
9
+ import { handbookPageSummary, handbookPageTitle } from "../../data/handbookDisplay";
10
+ import { HandbookPageInterface } from "../../data/HandbookPageInterface";
11
+
12
+ type HandbookSectionGroupProps = {
13
+ /** Rendered as the section's `h2`. Already prettified and translated by the caller. */
14
+ title: string;
15
+ /** The README blurb for the section, already translated by the caller. */
16
+ summary?: string;
17
+ /** The section's pages, already sorted by `order` by the caller. */
18
+ pages: HandbookPageInterface[];
19
+ };
20
+
21
+ /**
22
+ * One section of the contents page: a heading, its blurb, and its pages.
23
+ *
24
+ * A page whose path nests below the section's own directory
25
+ * (`02-framework/rulebook/frontend/01-models.md` inside `02-framework`) is not
26
+ * a peer of the section's direct pages, so it renders under a micro-label
27
+ * naming the intermediate directories. The nesting is read off the path rather
28
+ * than stored, because the filesystem is the only place it is declared.
29
+ */
30
+ export function HandbookSectionGroup({ title, summary, pages }: HandbookSectionGroupProps) {
31
+ const generateUrl = usePageUrlGenerator();
32
+
33
+ const { direct, nested } = useMemo(() => splitByNesting(pages), [pages]);
34
+
35
+ return (
36
+ <section className="flex flex-col gap-4">
37
+ {/*
38
+ The rule belongs to the heading block, not between the sections: it
39
+ marks where a section starts, so the generous gap above it reads as
40
+ separation and the tight gap below it as belonging.
41
+ */}
42
+ <div className="flex flex-col gap-1 border-b pb-3">
43
+ <SectionHeader level={2}>{title}</SectionHeader>
44
+ {summary ? <p className="text-muted-foreground text-sm">{summary}</p> : null}
45
+ </div>
46
+
47
+ {direct.length > 0 ? <HandbookPageRows pages={direct} generateUrl={generateUrl} /> : null}
48
+
49
+ {nested.map((group) => (
50
+ <div key={group.label} className="flex flex-col gap-1.5 pt-2">
51
+ <MicroLabel>{group.label}</MicroLabel>
52
+ <HandbookPageRows pages={group.pages} generateUrl={generateUrl} />
53
+ </div>
54
+ ))}
55
+ </section>
56
+ );
57
+ }
58
+
59
+ function HandbookPageRows({
60
+ pages,
61
+ generateUrl,
62
+ }: {
63
+ pages: HandbookPageInterface[];
64
+ generateUrl: ReturnType<typeof usePageUrlGenerator>;
65
+ }) {
66
+ return (
67
+ <ul className="divide-border/60 flex flex-col divide-y">
68
+ {pages.map((page) => {
69
+ // The translation when the ingest matched one, the English original
70
+ // when it did not. Resolved once per row rather than per element.
71
+ const summary = handbookPageSummary(page);
72
+
73
+ return (
74
+ <li key={page.id}>
75
+ {/*
76
+ The whole row is the link, not just the title: the summary describes
77
+ the same destination, and a second click target beside it would be a
78
+ second thing to aim at for no second outcome.
79
+
80
+ `text-sm` on the title and `text-xs` on the summary, not the
81
+ inherited base size — a row is a detail pair (roles 13 and 12), and
82
+ at base size it competed with the section heading above it instead
83
+ of sitting under it.
84
+ */}
85
+ <Link
86
+ href={generateUrl({ page: Modules.HandbookPage, id: page.id })}
87
+ className="hover:bg-muted/40 -mx-2 block rounded-md px-2 py-2"
88
+ >
89
+ <span className="text-primary block text-sm font-medium">{handbookPageTitle(page)}</span>
90
+ {summary ? (
91
+ <span className="text-muted-foreground mt-0.5 block text-xs font-normal">{sentenceCase(summary)}</span>
92
+ ) : null}
93
+ </Link>
94
+ </li>
95
+ );
96
+ })}
97
+ </ul>
98
+ );
99
+ }
100
+
101
+ /**
102
+ * The README writes each summary as a continuation of its link — "— boot, log
103
+ * in, create a proceeding" — so pasted in raw every row starts lowercase. Here
104
+ * the summary is a sentence of its own and reads as one.
105
+ *
106
+ * Only an ASCII letter is raised: several summaries open with a backtick
107
+ * (`` `defineEntity()` and everything derived from it ``) or a quotation mark,
108
+ * and those must survive untouched.
109
+ */
110
+ function sentenceCase(summary: string): string {
111
+ const first = summary.charAt(0);
112
+ if (first < "a" || first > "z") return summary;
113
+ return first.toUpperCase() + summary.slice(1);
114
+ }
115
+
116
+ /**
117
+ * Splits a section's pages into the ones that sit directly in its directory and
118
+ * the ones that sit in a directory below it, keyed by the intermediate path.
119
+ *
120
+ * `03-backend/testing.md` is direct. `02-framework/rulebook/frontend/01-models.md`
121
+ * belongs to the group `rulebook / frontend`. The groups come out in path order
122
+ * and always after the direct pages, so a section reads as its own pages first
123
+ * and its sub-topics after.
124
+ */
125
+ function splitByNesting(pages: HandbookPageInterface[]): {
126
+ direct: HandbookPageInterface[];
127
+ nested: { label: string; pages: HandbookPageInterface[] }[];
128
+ } {
129
+ const direct: HandbookPageInterface[] = [];
130
+ const groups = new Map<string, HandbookPageInterface[]>();
131
+
132
+ for (const page of pages) {
133
+ const segments = (page.order || page.path || "").split("/").filter((segment) => segment.length > 0);
134
+ // Drop the filename, then the section's own directory.
135
+ const intermediate = segments.slice(0, -1).slice(1);
136
+
137
+ if (intermediate.length === 0) {
138
+ direct.push(page);
139
+ continue;
140
+ }
141
+
142
+ const label = intermediate.join(" / ");
143
+ const existing = groups.get(label);
144
+ if (existing) existing.push(page);
145
+ else groups.set(label, [page]);
146
+ }
147
+
148
+ const nested = Array.from(groups.entries())
149
+ .sort((a, b) => a[0].localeCompare(b[0]))
150
+ .map(([label, groupPages]) => ({ label, pages: groupPages }));
151
+
152
+ return { direct, nested };
153
+ }