@carlonicora/nextjs-jsonapi 3.11.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 (156) 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-XOVNTXDY.js → BlockNoteEditor-CFVSHS46.js} +19 -19
  6. package/dist/{BlockNoteEditor-XOVNTXDY.js.map → BlockNoteEditor-CFVSHS46.js.map} +1 -1
  7. package/dist/{BlockNoteEditor-NBHDEZ7W.mjs → BlockNoteEditor-SERY337S.mjs} +4 -4
  8. package/dist/{BlockNoteViewer-C2UUOBAF.js → BlockNoteViewer-CWFPYYIU.js} +3 -3
  9. package/dist/{BlockNoteViewer-C2UUOBAF.js.map → BlockNoteViewer-CWFPYYIU.js.map} +1 -1
  10. package/dist/{BlockNoteViewer-TYLVTIUD.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.d.mts +27 -0
  16. package/dist/billing/index.d.ts +27 -0
  17. package/dist/billing/index.js +697 -450
  18. package/dist/billing/index.js.map +1 -1
  19. package/dist/billing/index.mjs +282 -35
  20. package/dist/billing/index.mjs.map +1 -1
  21. package/dist/{chunk-6J2SQZ3G.js → chunk-IB7DTXWG.js} +847 -129
  22. package/dist/chunk-IB7DTXWG.js.map +1 -0
  23. package/dist/{chunk-YN4LOVL4.js → chunk-LVE3BJ6K.js} +2106 -1109
  24. package/dist/chunk-LVE3BJ6K.js.map +1 -0
  25. package/dist/{chunk-M5QAHSCY.mjs → chunk-OSFYCSTW.mjs} +725 -7
  26. package/dist/chunk-OSFYCSTW.mjs.map +1 -0
  27. package/dist/{chunk-XNGI4HDZ.mjs → chunk-UQXE3SME.mjs} +2100 -1103
  28. package/dist/chunk-UQXE3SME.mjs.map +1 -0
  29. package/dist/{chunk-IMSIVIIC.mjs → chunk-VZKJIAND.mjs} +2 -2
  30. package/dist/{chunk-V6CGCARD.js → chunk-XH4IURUD.js} +7 -7
  31. package/dist/{chunk-V6CGCARD.js.map → chunk-XH4IURUD.js.map} +1 -1
  32. package/dist/client/index.d.mts +2 -2
  33. package/dist/client/index.d.ts +2 -2
  34. package/dist/client/index.js +4 -4
  35. package/dist/client/index.mjs +3 -3
  36. package/dist/components/index.d.mts +258 -7
  37. package/dist/components/index.d.ts +258 -7
  38. package/dist/components/index.js +30 -4
  39. package/dist/components/index.js.map +1 -1
  40. package/dist/components/index.mjs +29 -3
  41. package/dist/contexts/index.d.mts +17 -5
  42. package/dist/contexts/index.d.ts +17 -5
  43. package/dist/contexts/index.js +4 -4
  44. package/dist/contexts/index.mjs +3 -3
  45. package/dist/core/index.d.mts +493 -12
  46. package/dist/core/index.d.ts +493 -12
  47. package/dist/core/index.js +38 -2
  48. package/dist/core/index.js.map +1 -1
  49. package/dist/core/index.mjs +37 -1
  50. package/dist/features/help/index.js +37 -37
  51. package/dist/features/help/index.mjs +3 -3
  52. package/dist/features/tokenusage/index.js +101 -101
  53. package/dist/features/tokenusage/index.mjs +3 -3
  54. package/dist/index.d.mts +7 -7
  55. package/dist/index.d.ts +7 -7
  56. package/dist/index.js +39 -3
  57. package/dist/index.js.map +1 -1
  58. package/dist/index.mjs +38 -2
  59. package/dist/{notification.interface-DYF56CtE.d.ts → notification.interface-CjzV_KII.d.ts} +1 -1
  60. package/dist/{notification.interface-BWOCvKRP.d.mts → notification.interface-MnRt1Tjm.d.mts} +1 -1
  61. package/dist/{s3.service-D_dswjgz.d.mts → s3.service-C0Yq2Sx3.d.mts} +2 -2
  62. package/dist/{s3.service-DserU9VK.d.ts → s3.service-CUGarZs3.d.ts} +2 -2
  63. package/dist/server/index.d.mts +3 -3
  64. package/dist/server/index.d.ts +3 -3
  65. package/dist/server/index.js +3 -3
  66. package/dist/server/index.mjs +1 -1
  67. package/dist/{useSocket-BZG9oX-L.d.mts → useSocket-BcH7l7B8.d.mts} +1 -1
  68. package/dist/{useSocket-CiyTtYFj.d.ts → useSocket-DCZd_eJk.d.ts} +1 -1
  69. package/package.json +1 -1
  70. package/src/components/containers/ReactMarkdownContainer.tsx +61 -11
  71. package/src/components/index.ts +1 -0
  72. package/src/core/index.ts +5 -0
  73. package/src/core/registry/ModuleRegistry.ts +4 -0
  74. package/src/features/assistant/components/parts/AssistantComposer.tsx +22 -3
  75. package/src/features/assistant/components/parts/AssistantSidebar.tsx +13 -3
  76. package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +10 -3
  77. package/src/features/assistant/contexts/AssistantContext.tsx +30 -3
  78. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +32 -0
  79. package/src/features/assistant/data/Assistant.ts +2 -0
  80. package/src/features/assistant/data/AssistantInterface.ts +2 -0
  81. package/src/features/assistant/data/AssistantService.ts +4 -0
  82. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +5 -1
  83. package/src/features/assistant-message/data/AssistantMessage.ts +27 -2
  84. package/src/features/billing/i18n-keys.ts +7 -0
  85. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +108 -2
  86. package/src/features/billing/stripe-price/components/forms/__tests__/PriceEditor.spec.tsx +182 -0
  87. package/src/features/billing/stripe-price/components/lists/PricesList.tsx +152 -25
  88. package/src/features/billing/stripe-price/components/lists/__tests__/PricesList.spec.tsx +268 -0
  89. package/src/features/billing/stripe-price/data/__tests__/stripe-price.transfer.spec.ts +265 -0
  90. package/src/features/billing/stripe-price/data/stripe-price.fields.ts +1 -0
  91. package/src/features/billing/stripe-price/data/stripe-price.transfer.ts +194 -0
  92. package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +66 -2
  93. package/src/features/content/data/content.interface.ts +1 -1
  94. package/src/features/handbook/HandbookPageModule.ts +41 -0
  95. package/src/features/handbook/HandbookSectionModule.ts +35 -0
  96. package/src/features/handbook/HandbookThreadMessageModule.ts +21 -0
  97. package/src/features/handbook/HandbookThreadModule.ts +29 -0
  98. package/src/features/handbook/components/containers/HandbookAdminContainer.tsx +17 -0
  99. package/src/features/handbook/components/containers/HandbookAskContainer.tsx +142 -0
  100. package/src/features/handbook/components/containers/HandbookAskSheet.tsx +149 -0
  101. package/src/features/handbook/components/containers/HandbookPageContainer.tsx +143 -0
  102. package/src/features/handbook/components/containers/__tests__/HandbookAskContainer.spec.tsx +268 -0
  103. package/src/features/handbook/components/containers/__tests__/HandbookAskSheet.spec.tsx +143 -0
  104. package/src/features/handbook/components/containers/__tests__/HandbookPageContainer.spec.tsx +288 -0
  105. package/src/features/handbook/components/details/HandbookPageDetails.tsx +27 -0
  106. package/src/features/handbook/components/details/HandbookPageNeighbours.tsx +72 -0
  107. package/src/features/handbook/components/details/HandbookPageToc.tsx +165 -0
  108. package/src/features/handbook/components/forms/HandbookSyncButton.tsx +43 -0
  109. package/src/features/handbook/components/forms/__tests__/HandbookSyncButton.spec.tsx +42 -0
  110. package/src/features/handbook/components/index.ts +12 -0
  111. package/src/features/handbook/components/lists/HandbookContents.tsx +196 -0
  112. package/src/features/handbook/components/lists/HandbookSectionGroup.tsx +153 -0
  113. package/src/features/handbook/components/lists/__tests__/HandbookContents.spec.tsx +247 -0
  114. package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +62 -0
  115. package/src/features/handbook/components/parts/HandbookPageNavigator.tsx +143 -0
  116. package/src/features/handbook/contexts/HandbookContext.tsx +109 -0
  117. package/src/features/handbook/contexts/__tests__/HandbookContext.spec.tsx +131 -0
  118. package/src/features/handbook/data/HandbookPage.spec.ts +136 -0
  119. package/src/features/handbook/data/HandbookPage.ts +99 -0
  120. package/src/features/handbook/data/HandbookPageFields.ts +31 -0
  121. package/src/features/handbook/data/HandbookPageInput.ts +8 -0
  122. package/src/features/handbook/data/HandbookPageInterface.ts +29 -0
  123. package/src/features/handbook/data/HandbookPageService.ts +60 -0
  124. package/src/features/handbook/data/HandbookSection.spec.ts +58 -0
  125. package/src/features/handbook/data/HandbookSection.ts +73 -0
  126. package/src/features/handbook/data/HandbookSectionInput.ts +12 -0
  127. package/src/features/handbook/data/HandbookSectionInterface.ts +26 -0
  128. package/src/features/handbook/data/HandbookSectionService.ts +26 -0
  129. package/src/features/handbook/data/HandbookThread.ts +114 -0
  130. package/src/features/handbook/data/HandbookThreadInterface.ts +31 -0
  131. package/src/features/handbook/data/HandbookThreadMessage.ts +144 -0
  132. package/src/features/handbook/data/HandbookThreadMessageInterface.ts +35 -0
  133. package/src/features/handbook/data/HandbookThreadMessageService.ts +30 -0
  134. package/src/features/handbook/data/HandbookThreadService.ts +86 -0
  135. package/src/features/handbook/data/__tests__/HandbookThreadService.spec.ts +112 -0
  136. package/src/features/handbook/data/handbookDisplay.spec.ts +92 -0
  137. package/src/features/handbook/data/handbookDisplay.ts +55 -0
  138. package/src/features/handbook/data/index.ts +16 -0
  139. package/src/features/handbook/hooks/__tests__/useHandbookAsk.spec.ts +198 -0
  140. package/src/features/handbook/hooks/useHandbookAsk.ts +213 -0
  141. package/src/features/handbook/i18n-keys.ts +46 -0
  142. package/src/features/handbook/index.ts +7 -0
  143. package/src/features/how-to/components/forms/HowToEditor.tsx +0 -1
  144. package/src/features/how-to/components/forms/HowToReindexButton.spec.tsx +84 -0
  145. package/src/features/how-to/components/forms/HowToReindexButton.tsx +46 -0
  146. package/src/features/how-to/components/index.ts +1 -0
  147. package/src/features/how-to/components/lists/HowToList.tsx +5 -1
  148. package/src/features/how-to/data/HowTo.spec.ts +61 -1
  149. package/src/features/how-to/data/HowToService.ts +13 -0
  150. package/dist/chunk-6J2SQZ3G.js.map +0 -1
  151. package/dist/chunk-M5QAHSCY.mjs.map +0 -1
  152. package/dist/chunk-XNGI4HDZ.mjs.map +0 -1
  153. package/dist/chunk-YN4LOVL4.js.map +0 -1
  154. /package/dist/{BlockNoteEditor-NBHDEZ7W.mjs.map → BlockNoteEditor-SERY337S.mjs.map} +0 -0
  155. /package/dist/{BlockNoteViewer-TYLVTIUD.mjs.map → BlockNoteViewer-P2VKEFRN.mjs.map} +0 -0
  156. /package/dist/{chunk-IMSIVIIC.mjs.map → chunk-VZKJIAND.mjs.map} +0 -0
@@ -0,0 +1,288 @@
1
+ import { render, screen, within } from "@testing-library/react";
2
+ import { ReactNode } from "react";
3
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { configureI18n } from "../../../../../i18n";
6
+ import { ApiRequestDataTypeInterface } from "../../../../../core/interfaces/ApiRequestDataTypeInterface";
7
+ import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
8
+ import { HandbookPageService } from "../../../data/HandbookPageService";
9
+ import { HandbookSectionService } from "../../../data/HandbookSectionService";
10
+ import HandbookPageContainer from "../HandbookPageContainer";
11
+
12
+ // The fixtures are the contents surface's own (Task G), plus `content` for the
13
+ // page under test. `vi.mock` factories are hoisted above every module-scope
14
+ // const, so the rows come from `vi.hoisted()` rather than from a plain const.
15
+ const { pages, sections } = vi.hoisted(() => ({
16
+ pages: [
17
+ {
18
+ id: "p1",
19
+ title: "Reading order",
20
+ section: "00-start-here",
21
+ order: "00-start-here/reading-order.md",
22
+ path: "00-start-here/reading-order.md",
23
+ summary: "The three paths.",
24
+ aiStatus: "completed",
25
+ wordCount: 10,
26
+ content: "# Reading order\n",
27
+ },
28
+ {
29
+ id: "p2",
30
+ title: "Testing the API",
31
+ section: "03-backend",
32
+ order: "03-backend/testing.md",
33
+ path: "03-backend/testing.md",
34
+ summary: undefined,
35
+ aiStatus: "failed",
36
+ wordCount: 20,
37
+ content: "# Testing the API\n\n## What is mocked\n\nEverything.\n",
38
+ },
39
+ {
40
+ id: "p3",
41
+ title: "Frontend models",
42
+ section: "02-framework",
43
+ order: "02-framework/rulebook/frontend/01-models.md",
44
+ path: "02-framework/rulebook/frontend/01-models.md",
45
+ summary: "AbstractApiData.",
46
+ aiStatus: "completed",
47
+ wordCount: 30,
48
+ content: "# Frontend models\n",
49
+ },
50
+ ],
51
+ sections: [
52
+ { id: "s1", key: "00-start-here", title: "Start here", summary: "Get the stack running.", order: "00-start-here" },
53
+ { id: "s2", key: "02-framework", title: "Framework", summary: undefined, order: "02-framework" },
54
+ { id: "s3", key: "03-backend", title: "Backend", summary: "The API.", order: "03-backend" },
55
+ ],
56
+ }));
57
+
58
+ vi.mock("../../../data/HandbookPageService", () => ({
59
+ HandbookPageService: { findOne: vi.fn(), findMany: vi.fn() },
60
+ }));
61
+
62
+ vi.mock("../../../data/HandbookSectionService", () => ({
63
+ HandbookSectionService: { findMany: vi.fn() },
64
+ }));
65
+
66
+ // The header is the provider's job (Task F) and the sheet is the ask surface's
67
+ // (Task I). Neither is under test here, and both need providers this spec has
68
+ // no reason to mount.
69
+ vi.mock("../../../contexts/HandbookContext", () => ({
70
+ HandbookProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
71
+ useHandbookContext: () => ({}),
72
+ }));
73
+
74
+ vi.mock("../HandbookAskSheet", () => ({
75
+ HandbookAskSheet: () => <div data-testid="handbook-ask-sheet" />,
76
+ }));
77
+
78
+ // RoundPageContainer is page chrome — header, details panel, url rewriting.
79
+ // Stubbed exactly as HandbookAskContainer.spec.tsx stubs it.
80
+ vi.mock("../../../../../components/containers/RoundPageContainer", () => ({
81
+ RoundPageContainer: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
82
+ }));
83
+
84
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
85
+ try {
86
+ ModuleRegistry.get(key as any);
87
+ } catch {
88
+ ModuleRegistry.register(key, module);
89
+ }
90
+ };
91
+
92
+ beforeAll(() => {
93
+ registerIfAbsent("HandbookPage", {
94
+ name: "handbookpages",
95
+ pageUrl: "/administration/handbook",
96
+ } as any);
97
+
98
+ // The package `Link` resolves the app's next-intl Link at runtime; without a
99
+ // configured i18n it throws rather than rendering an anchor.
100
+ configureI18n({
101
+ useRouter: () => ({
102
+ push: () => {},
103
+ replace: () => {},
104
+ back: () => {},
105
+ forward: () => {},
106
+ refresh: () => {},
107
+ prefetch: () => {},
108
+ }),
109
+ useTranslations: () => (key: string) => key,
110
+ usePathname: () => "/administration/handbook",
111
+ Link: ({ href, children, ...props }: any) => (
112
+ <a href={href} {...props}>
113
+ {children}
114
+ </a>
115
+ ),
116
+ });
117
+ });
118
+
119
+ describe("HandbookPageContainer", () => {
120
+ beforeEach(() => {
121
+ vi.mocked(HandbookPageService.findOne)
122
+ .mockReset()
123
+ .mockImplementation(async ({ id }: { id: string }) => pages.find((page) => page.id === id) as any);
124
+ vi.mocked(HandbookPageService.findMany)
125
+ .mockReset()
126
+ .mockResolvedValue(pages as any);
127
+ vi.mocked(HandbookSectionService.findMany)
128
+ .mockReset()
129
+ .mockResolvedValue(sections as any);
130
+ });
131
+
132
+ it("renders the page title in the body once, and no path line", async () => {
133
+ render(<HandbookPageContainer handbookPageId="p2" />);
134
+
135
+ // The markdown's own H1 stays; the body's SectionHeader and path line are gone.
136
+ expect(await screen.findByRole("heading", { level: 1, name: "Testing the API" })).toBeInTheDocument();
137
+ expect(screen.queryByText("03-backend/testing.md")).not.toBeInTheDocument();
138
+ });
139
+
140
+ it("lists every page in the navigator with the current one marked", async () => {
141
+ render(<HandbookPageContainer handbookPageId="p2" />);
142
+
143
+ const nav = await screen.findByRole("navigation", { name: /contents/i });
144
+ expect(within(nav).getAllByRole("link")).toHaveLength(3);
145
+ expect(within(nav).getByRole("link", { current: "page" })).toHaveTextContent("Testing the API");
146
+ });
147
+
148
+ it("orders the navigator by section then order", async () => {
149
+ render(<HandbookPageContainer handbookPageId="p2" />);
150
+
151
+ const nav = await screen.findByRole("navigation", { name: /contents/i });
152
+ expect(
153
+ within(nav)
154
+ .getAllByRole("link")
155
+ .map((link) => link.textContent),
156
+ ).toEqual(["Reading order", "Frontend models", "Testing the API"]);
157
+ });
158
+
159
+ it("links the ordered neighbours", async () => {
160
+ render(<HandbookPageContainer handbookPageId="p2" />);
161
+
162
+ // "Frontend models" is the page before this one, so it renders twice: once
163
+ // in the navigator, once as the `previous` neighbour at the foot.
164
+ const links = await screen.findAllByRole("link", { name: /Frontend models/ });
165
+ expect(links).toHaveLength(2);
166
+ const previous = links.find((link) => link.textContent?.includes("handbook.reader.previous"));
167
+ expect(previous).toBeDefined();
168
+ expect(previous).toHaveAttribute("href", "/administration/handbook/p3");
169
+ // p2 is the last page in the order, so there is no `next` at all.
170
+ expect(screen.queryByText("handbook.reader.next")).not.toBeInTheDocument();
171
+ });
172
+
173
+ it("builds a heading index from the rendered markdown", async () => {
174
+ render(<HandbookPageContainer handbookPageId="p2" />);
175
+
176
+ // The visible label and the landmark name are the same key, which
177
+ // next-intl's spec mock renders verbatim.
178
+ const toc = await screen.findByRole("navigation", { name: /onThisPage/i });
179
+ expect(within(toc).getByRole("link", { name: "What is mocked" })).toHaveAttribute("href", "#what-is-mocked");
180
+ });
181
+
182
+ /**
183
+ * Indexed in English, shown translated. The reader has three surfaces that
184
+ * print handbook words — the body, the navigator and the prev/next pair — and
185
+ * all three resolve the same way.
186
+ *
187
+ * Only p2 (the page under test) and p3 (its `previous`) carry a translation,
188
+ * so one render proves both the translated and the fallback branch.
189
+ */
190
+ describe("display translation", () => {
191
+ const translated = pages.map((page) => {
192
+ if (page.id === "p2")
193
+ return {
194
+ ...page,
195
+ displayTitle: "Testare le API",
196
+ displayContent: "# Testare le API\n\n## Cosa è mockato\n\nTutto.\n",
197
+ };
198
+ if (page.id === "p3") return { ...page, displayTitle: "Modelli frontend" };
199
+ return page;
200
+ });
201
+
202
+ beforeEach(() => {
203
+ vi.mocked(HandbookPageService.findOne).mockImplementation(
204
+ async ({ id }: { id: string }) => translated.find((page) => page.id === id) as any,
205
+ );
206
+ vi.mocked(HandbookPageService.findMany).mockResolvedValue(translated as any);
207
+ });
208
+
209
+ it("renders the translated body", async () => {
210
+ render(<HandbookPageContainer handbookPageId="p2" />);
211
+
212
+ expect(await screen.findByRole("heading", { level: 1, name: "Testare le API" })).toBeInTheDocument();
213
+ expect(screen.queryByRole("heading", { level: 1, name: "Testing the API" })).not.toBeInTheDocument();
214
+ });
215
+
216
+ it("renders the English body for a page with no translation", async () => {
217
+ render(<HandbookPageContainer handbookPageId="p1" />);
218
+
219
+ expect(await screen.findByRole("heading", { level: 1, name: "Reading order" })).toBeInTheDocument();
220
+ });
221
+
222
+ it("labels the navigator entries with the translation, falling back per page", async () => {
223
+ render(<HandbookPageContainer handbookPageId="p2" />);
224
+
225
+ const nav = await screen.findByRole("navigation", { name: /contents/i });
226
+ // Still ordered by `section` then `order`, which stay English: only the
227
+ // labels changed.
228
+ expect(
229
+ within(nav)
230
+ .getAllByRole("link")
231
+ .map((link) => link.textContent),
232
+ ).toEqual(["Reading order", "Modelli frontend", "Testare le API"]);
233
+ expect(within(nav).getByRole("link", { current: "page" })).toHaveTextContent("Testare le API");
234
+ });
235
+
236
+ it("labels the previous neighbour with its translation", async () => {
237
+ render(<HandbookPageContainer handbookPageId="p2" />);
238
+
239
+ const links = await screen.findAllByRole("link", { name: /Modelli frontend/ });
240
+ const previous = links.find((link) => link.textContent?.includes("handbook.reader.previous"));
241
+ expect(previous).toBeDefined();
242
+ // The link still points at the same page: the translation changes the
243
+ // words, never the destination.
244
+ expect(previous).toHaveAttribute("href", "/administration/handbook/p3");
245
+ });
246
+
247
+ it("labels the navigator sections with their translation, falling back per section", async () => {
248
+ vi.mocked(HandbookSectionService.findMany).mockResolvedValue(
249
+ sections.map((section) =>
250
+ section.key === "03-backend" ? { ...section, displayTitle: "Backend (API)" } : section,
251
+ ) as any,
252
+ );
253
+
254
+ render(<HandbookPageContainer handbookPageId="p2" />);
255
+
256
+ const nav = await screen.findByRole("navigation", { name: /contents/i });
257
+ expect(
258
+ within(nav)
259
+ .getAllByRole("heading")
260
+ .map((heading) => heading.textContent),
261
+ ).toEqual(["Start here", "Framework", "Backend (API)"]);
262
+ });
263
+ });
264
+
265
+ it("keeps the not-found branch when the id resolves to nothing", async () => {
266
+ vi.mocked(HandbookPageService.findOne).mockResolvedValue(undefined as any);
267
+
268
+ render(<HandbookPageContainer handbookPageId="ghost" />);
269
+
270
+ expect(await screen.findByText("handbook.notFound")).toBeInTheDocument();
271
+ expect(screen.queryByRole("navigation", { name: /contents/i })).not.toBeInTheDocument();
272
+ });
273
+
274
+ it("still renders the navigator when the section load fails", async () => {
275
+ vi.mocked(HandbookSectionService.findMany).mockRejectedValue(new Error("500"));
276
+
277
+ render(<HandbookPageContainer handbookPageId="p2" />);
278
+
279
+ const nav = await screen.findByRole("navigation", { name: /contents/i });
280
+ expect(within(nav).getAllByRole("link")).toHaveLength(3);
281
+ // Raw section keys as headings, in key order.
282
+ expect(
283
+ within(nav)
284
+ .getAllByRole("heading")
285
+ .map((heading) => heading.textContent),
286
+ ).toEqual(["00-start-here", "02-framework", "03-backend"]);
287
+ });
288
+ });
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { ReactMarkdownContainer } from "../../../../components";
4
+ import { handbookPageContent } from "../../data/handbookDisplay";
5
+ import { HandbookPageInterface } from "../../data/HandbookPageInterface";
6
+
7
+ type HandbookPageDetailsProps = {
8
+ handbookPage: HandbookPageInterface;
9
+ };
10
+
11
+ /**
12
+ * `content` is markdown read from a file and rendered read-only. It is NOT a
13
+ * BlockNote field: nothing edits it in the app, so no editor and no
14
+ * blocks-to-text conversion is involved.
15
+ *
16
+ * The title and the file path are NOT rendered here. The reader's header owns
17
+ * both — it names the section and the page — and repeating them above the
18
+ * markdown's own H1 printed the title twice on every page.
19
+ *
20
+ * The body comes from `handbookPageContent`, so a page whose translation the
21
+ * ingest matched renders in the reader's language and one without it falls back
22
+ * to the English file. This is the detail read, which is the only read that
23
+ * carries `displayContent` at all.
24
+ */
25
+ export function HandbookPageDetails({ handbookPage }: HandbookPageDetailsProps) {
26
+ return <ReactMarkdownContainer content={handbookPageContent(handbookPage)} />;
27
+ }
@@ -0,0 +1,72 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useMemo } from "react";
5
+
6
+ import { MicroLabel } from "../../../../components";
7
+ import { Modules } from "../../../../core";
8
+ import { usePageUrlGenerator } from "../../../../hooks";
9
+ import { Link } from "../../../../shadcnui";
10
+ import { handbookPageTitle } from "../../data/handbookDisplay";
11
+ import { HandbookPageInterface } from "../../data/HandbookPageInterface";
12
+
13
+ type HandbookPageNeighboursProps = {
14
+ pages: HandbookPageInterface[];
15
+ currentId: string;
16
+ };
17
+
18
+ /**
19
+ * Previous and next, at the foot of the page.
20
+ *
21
+ * Walks the same ordering the navigator renders — section key, then `order`,
22
+ * which is the repo-relative path — so reading straight through the manual with
23
+ * these two links reproduces the contents page from top to bottom. Below `md`
24
+ * the navigator is hidden and this pair is the only way forward that is not the
25
+ * breadcrumb.
26
+ *
27
+ * Nothing renders at either end of the list: no disabled control, no empty slot.
28
+ *
29
+ * The ordering is read off `section` and `order`, which stay English; only the
30
+ * two labels are translated.
31
+ */
32
+ export function HandbookPageNeighbours({ pages, currentId }: HandbookPageNeighboursProps) {
33
+ const t = useTranslations();
34
+ const generateUrl = usePageUrlGenerator();
35
+
36
+ const { previous, next } = useMemo(() => {
37
+ const ordered = [...pages].sort((a, b) =>
38
+ `${a.section ?? ""}/${a.order ?? ""}`.localeCompare(`${b.section ?? ""}/${b.order ?? ""}`),
39
+ );
40
+ const index = ordered.findIndex((page) => page.id === currentId);
41
+ if (index === -1) return { previous: undefined, next: undefined };
42
+
43
+ return { previous: ordered[index - 1], next: ordered[index + 1] };
44
+ }, [pages, currentId]);
45
+
46
+ if (!previous && !next) return null;
47
+
48
+ return (
49
+ <div className="mt-8 grid gap-4 border-t pt-4 md:grid-cols-2">
50
+ {previous ? (
51
+ <Link
52
+ href={generateUrl({ page: Modules.HandbookPage, id: previous.id })}
53
+ className="flex flex-col gap-y-1 text-start"
54
+ >
55
+ <MicroLabel>{t("handbook.reader.previous")}</MicroLabel>
56
+ <span className="text-primary text-sm font-medium">{handbookPageTitle(previous)}</span>
57
+ </Link>
58
+ ) : (
59
+ <span />
60
+ )}
61
+ {next ? (
62
+ <Link
63
+ href={generateUrl({ page: Modules.HandbookPage, id: next.id })}
64
+ className="flex flex-col gap-y-1 text-end md:items-end"
65
+ >
66
+ <MicroLabel>{t("handbook.reader.next")}</MicroLabel>
67
+ <span className="text-primary text-sm font-medium">{handbookPageTitle(next)}</span>
68
+ </Link>
69
+ ) : null}
70
+ </div>
71
+ );
72
+ }
@@ -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";