@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,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
+ }
@@ -0,0 +1,247 @@
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 { ApiRequestDataTypeInterface } from "../../../../../core/interfaces/ApiRequestDataTypeInterface";
6
+ import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
7
+ import { configureI18n } from "../../../../../i18n";
8
+ import { HandbookPageService } from "../../../data/HandbookPageService";
9
+ import { HandbookSectionService } from "../../../data/HandbookSectionService";
10
+ import HandbookContents from "../HandbookContents";
11
+
12
+ // `vi.mock` factories are hoisted above every module-scope const, so the rows
13
+ // come from `vi.hoisted()` rather than from a plain const.
14
+ const { pages, sections } = vi.hoisted(() => ({
15
+ pages: [
16
+ {
17
+ id: "p1",
18
+ title: "Reading order",
19
+ section: "00-start-here",
20
+ order: "00-start-here/reading-order.md",
21
+ path: "00-start-here/reading-order.md",
22
+ summary: "The three paths.",
23
+ aiStatus: "completed",
24
+ wordCount: 10,
25
+ },
26
+ {
27
+ id: "p2",
28
+ title: "Testing the API",
29
+ section: "03-backend",
30
+ order: "03-backend/testing.md",
31
+ path: "03-backend/testing.md",
32
+ summary: undefined,
33
+ aiStatus: "failed",
34
+ wordCount: 20,
35
+ },
36
+ {
37
+ id: "p3",
38
+ title: "Frontend models",
39
+ section: "02-framework",
40
+ order: "02-framework/rulebook/frontend/01-models.md",
41
+ path: "02-framework/rulebook/frontend/01-models.md",
42
+ summary: "AbstractApiData.",
43
+ aiStatus: "completed",
44
+ wordCount: 30,
45
+ },
46
+ ],
47
+ sections: [
48
+ { id: "s1", key: "00-start-here", title: "Start here", summary: "Get the stack running.", order: "00-start-here" },
49
+ { id: "s2", key: "02-framework", title: "Framework", summary: undefined, order: "02-framework" },
50
+ { id: "s3", key: "03-backend", title: "Backend", summary: "The API.", order: "03-backend" },
51
+ ],
52
+ }));
53
+
54
+ vi.mock("../../../data/HandbookPageService", () => ({
55
+ HandbookPageService: { findMany: vi.fn(), sync: vi.fn() },
56
+ }));
57
+
58
+ vi.mock("../../../data/HandbookSectionService", () => ({
59
+ HandbookSectionService: { findMany: vi.fn() },
60
+ }));
61
+
62
+ // The sheet is the ask surface's (Task I). It is not under test here, and it
63
+ // needs providers this spec has no reason to mount.
64
+ vi.mock("../../containers/HandbookAskSheet", () => ({
65
+ HandbookAskSheet: () => <div data-testid="handbook-ask-sheet" />,
66
+ }));
67
+
68
+ // The suite-wide next-intl stub returns the key and drops the values, which
69
+ // would hide the two numbers the status line exists to show. This one keeps
70
+ // them, so `{count} pagine · sincronizzato {date}` is still asserted on the
71
+ // numbers rather than on a translated string this package does not own.
72
+ vi.mock("next-intl", () => {
73
+ const makeT = () => {
74
+ const t = (key: string, values?: Record<string, unknown>) =>
75
+ values ? `${key} ${Object.values(values).join(" ")}`.trimEnd() : key;
76
+ (t as any).has = () => true;
77
+ return t;
78
+ };
79
+ return {
80
+ useTranslations: () => makeT(),
81
+ useLocale: () => "en",
82
+ useNow: () => new Date(),
83
+ useTimeZone: () => "UTC",
84
+ useFormatter: () => ({
85
+ dateTime: () => "sync-date",
86
+ number: (value: unknown) => String(value),
87
+ relativeTime: () => "",
88
+ }),
89
+ };
90
+ });
91
+
92
+ // The header is the provider's job (Task F). Stubbed as
93
+ // HandbookPageContainer.spec.tsx stubs it, except that this stub renders
94
+ // `functions`: the ask launcher, Sincronizza and the status line are handed to
95
+ // the header through that prop, so dropping it would delete half of this
96
+ // component's output from the test.
97
+ vi.mock("../../../contexts/HandbookContext", () => ({
98
+ HandbookProvider: ({ children, functions }: { children?: ReactNode; functions?: ReactNode }) => (
99
+ <>
100
+ <div data-testid="page-functions">{functions}</div>
101
+ {children}
102
+ </>
103
+ ),
104
+ useHandbookContext: () => ({}),
105
+ }));
106
+
107
+ // RoundPageContainer is page chrome — header, details panel, url rewriting.
108
+ // Stubbed exactly as HandbookAskContainer.spec.tsx stubs it.
109
+ vi.mock("../../../../../components/containers/RoundPageContainer", () => ({
110
+ RoundPageContainer: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
111
+ }));
112
+
113
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
114
+ try {
115
+ ModuleRegistry.get(key as any);
116
+ } catch {
117
+ ModuleRegistry.register(key, module);
118
+ }
119
+ };
120
+
121
+ beforeAll(() => {
122
+ registerIfAbsent("HandbookPage", {
123
+ name: "handbookpages",
124
+ pageUrl: "/administration/handbook",
125
+ } as any);
126
+
127
+ // The package `Link` resolves the app's next-intl Link at runtime; without a
128
+ // configured i18n it throws rather than rendering an anchor.
129
+ configureI18n({
130
+ useRouter: () => ({
131
+ push: () => {},
132
+ replace: () => {},
133
+ back: () => {},
134
+ forward: () => {},
135
+ refresh: () => {},
136
+ prefetch: () => {},
137
+ }),
138
+ useTranslations: () => (key: string) => key,
139
+ usePathname: () => "/administration/handbook",
140
+ Link: ({ href, children, ...props }: any) => (
141
+ <a href={href} {...props}>
142
+ {children}
143
+ </a>
144
+ ),
145
+ });
146
+ });
147
+
148
+ describe("HandbookContents", () => {
149
+ beforeEach(() => {
150
+ vi.mocked(HandbookPageService.findMany)
151
+ .mockReset()
152
+ .mockResolvedValue(pages as any);
153
+ vi.mocked(HandbookSectionService.findMany)
154
+ .mockReset()
155
+ .mockResolvedValue(sections as any);
156
+ });
157
+
158
+ it("renders sections in order with their blurbs", async () => {
159
+ render(<HandbookContents />);
160
+ const headings = await screen.findAllByRole("heading", { level: 2 });
161
+ expect(headings.map((h) => h.textContent)).toEqual(["Start here", "Framework", "Backend"]);
162
+ expect(screen.getByText("Get the stack running.")).toBeInTheDocument();
163
+ });
164
+
165
+ it("renders a page summary when it has one and nothing when it does not", async () => {
166
+ render(<HandbookContents />);
167
+ expect(await screen.findByText("The three paths.")).toBeInTheDocument();
168
+ const row = screen.getByRole("link", { name: /Testing the API/ });
169
+ expect(within(row).queryByText("—")).not.toBeInTheDocument();
170
+ expect(row.textContent).toBe("Testing the API");
171
+ });
172
+
173
+ it("groups a nested page under its intermediate directory", async () => {
174
+ render(<HandbookContents />);
175
+ expect(await screen.findByText("rulebook / frontend")).toBeInTheDocument();
176
+ });
177
+
178
+ it("counts failures in the status line", async () => {
179
+ render(<HandbookContents />);
180
+ expect(await screen.findByText(/1/)).toBeInTheDocument();
181
+ });
182
+
183
+ it("renders no table", async () => {
184
+ render(<HandbookContents />);
185
+ await screen.findAllByRole("heading", { level: 2 });
186
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
187
+ });
188
+
189
+ /**
190
+ * The manual is INDEXED in English and SHOWN translated: every string on this
191
+ * surface prefers the translation that travels beside the English original,
192
+ * and falls back to the original when the ingest matched none.
193
+ *
194
+ * The fixtures are the suite's own, with a translation added to exactly one
195
+ * page and one section — so the same render proves both halves of the rule.
196
+ */
197
+ describe("display translation", () => {
198
+ const translatedPages = pages.map((page) =>
199
+ page.id === "p1" ? { ...page, displayTitle: "Ordine di lettura", displaySummary: "I tre percorsi." } : page,
200
+ );
201
+ const translatedSections = sections.map((section) =>
202
+ section.key === "00-start-here"
203
+ ? { ...section, displayTitle: "Da qui si parte", displaySummary: "Avvia lo stack." }
204
+ : section,
205
+ );
206
+
207
+ beforeEach(() => {
208
+ vi.mocked(HandbookPageService.findMany).mockResolvedValue(translatedPages as any);
209
+ vi.mocked(HandbookSectionService.findMany).mockResolvedValue(translatedSections as any);
210
+ });
211
+
212
+ it("renders a translated section heading and blurb, and the English ones where there is no translation", async () => {
213
+ render(<HandbookContents />);
214
+ const headings = await screen.findAllByRole("heading", { level: 2 });
215
+ expect(headings.map((h) => h.textContent)).toEqual(["Da qui si parte", "Framework", "Backend"]);
216
+ expect(screen.getByText("Avvia lo stack.")).toBeInTheDocument();
217
+ expect(screen.queryByText("Get the stack running.")).not.toBeInTheDocument();
218
+ // The untranslated section still reads in English.
219
+ expect(screen.getByText("The API.")).toBeInTheDocument();
220
+ });
221
+
222
+ it("renders a translated page row and falls back to English for an untranslated one", async () => {
223
+ render(<HandbookContents />);
224
+ expect(await screen.findByRole("link", { name: /Ordine di lettura/ })).toBeInTheDocument();
225
+ expect(screen.queryByRole("link", { name: /Reading order/ })).not.toBeInTheDocument();
226
+ expect(screen.getByText("I tre percorsi.")).toBeInTheDocument();
227
+ expect(screen.getByRole("link", { name: /Frontend models/ })).toBeInTheDocument();
228
+ });
229
+
230
+ it("orders sections and pages by the untranslated keys", async () => {
231
+ render(<HandbookContents />);
232
+ const headings = await screen.findAllByRole("heading", { level: 2 });
233
+ // `order` and `section` stay English, so the translated section keeps its
234
+ // place rather than sorting under its Italian initial.
235
+ expect(headings.map((h) => h.textContent)).toEqual(["Da qui si parte", "Framework", "Backend"]);
236
+ expect(await screen.findByText("rulebook / frontend")).toBeInTheDocument();
237
+ });
238
+ });
239
+
240
+ it("falls back to raw section keys when the section load fails", async () => {
241
+ vi.mocked(HandbookSectionService.findMany).mockRejectedValueOnce(new Error("500"));
242
+ render(<HandbookContents />);
243
+ const headings = await screen.findAllByRole("heading", { level: 2 });
244
+ expect(headings.map((h) => h.textContent)).toEqual(["00-start-here", "02-framework", "03-backend"]);
245
+ expect(screen.getByRole("link", { name: /Reading order/ })).toBeInTheDocument();
246
+ });
247
+ });
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useFormatter, useTranslations } from "next-intl";
4
+ import { useMemo } from "react";
5
+
6
+ import { HandbookPageInterface } from "../../data/HandbookPageInterface";
7
+
8
+ type HandbookIndexStatusProps = {
9
+ pages: HandbookPageInterface[];
10
+ };
11
+
12
+ /**
13
+ * `aiStatus` values written by the ingest. Only `failed` means the page is not
14
+ * answerable; `pending` and `in_progress` are a sync still running and say
15
+ * nothing about the index being wrong.
16
+ */
17
+ const FAILURE_STATUSES = new Set(["failed", "error"]);
18
+
19
+ /**
20
+ * The one line of index bookkeeping the reading surface keeps.
21
+ *
22
+ * Word counts and per-page AI badges left the contents page; how many pages
23
+ * there are and when they were last synced did not, because that is the one
24
+ * question the administrator who pressed Sincronizza actually has. Both values
25
+ * are derived from the loaded pages — nothing new is stored for either, and
26
+ * `updatedAt` is framework-managed.
27
+ */
28
+ export function HandbookIndexStatus({ pages }: HandbookIndexStatusProps) {
29
+ const t = useTranslations();
30
+ const format = useFormatter();
31
+
32
+ const lastSynced = useMemo(() => {
33
+ let latest: Date | undefined = undefined;
34
+
35
+ for (const page of pages) {
36
+ const updatedAt = page.updatedAt;
37
+ if (!(updatedAt instanceof Date) || Number.isNaN(updatedAt.getTime())) continue;
38
+ if (!latest || updatedAt.getTime() > latest.getTime()) latest = updatedAt;
39
+ }
40
+
41
+ return latest;
42
+ }, [pages]);
43
+
44
+ const failures = useMemo(
45
+ () => pages.filter((page) => FAILURE_STATUSES.has((page.aiStatus ?? "").toLowerCase())).length,
46
+ [pages],
47
+ );
48
+
49
+ return (
50
+ <span className="text-muted-foreground text-xs tabular-nums">
51
+ {t("handbook.contents.status", {
52
+ count: pages.length,
53
+ date: lastSynced ? format.dateTime(lastSynced, { dateStyle: "medium" }) : "",
54
+ })}
55
+ {failures > 0 ? (
56
+ <span className="text-destructive ms-2 tabular-nums">
57
+ {t("handbook.contents.failures", { count: failures })}
58
+ </span>
59
+ ) : null}
60
+ </span>
61
+ );
62
+ }