@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,143 @@
1
+ "use client";
2
+
3
+ import { BookOpenIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useEffect, useMemo, useRef } from "react";
6
+
7
+ import { MicroLabel } from "../../../../components";
8
+ import { Modules } from "../../../../core";
9
+ import { usePageUrlGenerator } from "../../../../hooks";
10
+ import { Link } from "../../../../shadcnui";
11
+ import { cn } from "../../../../utils/cn";
12
+ import { handbookPageTitle, handbookSectionTitle } from "../../data/handbookDisplay";
13
+ import { HandbookPageInterface } from "../../data/HandbookPageInterface";
14
+ import { HandbookSectionInterface } from "../../data/HandbookSectionInterface";
15
+
16
+ type HandbookPageNavigatorProps = {
17
+ pages: HandbookPageInterface[];
18
+ sections: HandbookSectionInterface[];
19
+ currentId: string;
20
+ };
21
+
22
+ /**
23
+ * The manual's section tree, beside the page being read.
24
+ *
25
+ * A list of links, NOT `RoundPageContainer`'s `layout="rail"`: the rail renders
26
+ * a `TabsContent` per tab, so 120 pages would mean 120 content nodes and a
27
+ * client-side tab switch. A manual wants 120 real, shareable URLs and a working
28
+ * back button, which is what an anchor gives and a tab does not.
29
+ *
30
+ * Degrades the same way the contents surface does: a page whose `section`
31
+ * matches no loaded section — or has none at all — lands in a trailing group,
32
+ * and if no sections loaded at all the distinct page keys are used as written.
33
+ */
34
+ export function HandbookPageNavigator({ pages, sections, currentId }: HandbookPageNavigatorProps) {
35
+ const t = useTranslations();
36
+ const generateUrl = usePageUrlGenerator();
37
+
38
+ const groups = useMemo(() => {
39
+ const byKey = new Map<string, HandbookPageInterface[]>();
40
+ for (const page of pages) {
41
+ const key = page.section ?? "";
42
+ const bucket = byKey.get(key);
43
+ if (bucket) bucket.push(page);
44
+ else byKey.set(key, [page]);
45
+ }
46
+
47
+ for (const bucket of byKey.values()) bucket.sort((a, b) => (a.order ?? "").localeCompare(b.order ?? ""));
48
+
49
+ const ordered: { key: string; title: string; pages: HandbookPageInterface[] }[] = [];
50
+ const consumed = new Set<string>();
51
+
52
+ for (const section of sections) {
53
+ const sectionPages = byKey.get(section.key);
54
+ if (!sectionPages) continue;
55
+ consumed.add(section.key);
56
+ ordered.push({ key: section.key, title: handbookSectionTitle(section), pages: sectionPages });
57
+ }
58
+
59
+ // Whatever the loaded sections did not describe: an unknown key, an empty
60
+ // one, or every key when the section load failed outright.
61
+ const leftovers = [...byKey.keys()].filter((key) => !consumed.has(key)).sort((a, b) => a.localeCompare(b));
62
+
63
+ for (const key of leftovers)
64
+ ordered.push({
65
+ key: key.length > 0 ? key : "__untitled",
66
+ title: key.length > 0 ? key : t("handbook.sections.untitled"),
67
+ pages: byKey.get(key) ?? [],
68
+ });
69
+
70
+ return ordered;
71
+ }, [pages, sections, t]);
72
+
73
+ const currentRef = useRef<HTMLAnchorElement | null>(null);
74
+
75
+ /*
76
+ Bring the current page into view inside the rail — 120 entries means it is
77
+ usually below the fold, and a position marker nobody can see marks nothing.
78
+
79
+ Deliberately NOT `scrollIntoView`: that walks up every scrollable ancestor,
80
+ so it would scroll the page itself and land the reader halfway down the
81
+ article they just opened. Setting `scrollTop` on the rail moves the rail and
82
+ only the rail.
83
+ */
84
+ useEffect(() => {
85
+ const link = currentRef.current;
86
+ if (!link) return;
87
+
88
+ const rail = link.closest<HTMLElement>(".overflow-y-auto");
89
+ if (!rail) return;
90
+
91
+ rail.scrollTop = link.offsetTop - rail.clientHeight / 2 + link.clientHeight / 2;
92
+ }, [currentId, groups]);
93
+
94
+ if (groups.length === 0) return null;
95
+
96
+ // No `max-h` / `overflow` on the nav: the reader's rail wrapper is the sticky
97
+ // scroll container, and a second one nested inside it would trap the scroll
98
+ // in the wrong element.
99
+ /*
100
+ Deliberately the same treatment as `HandbookPageToc`: an icon and a
101
+ micro-label at the top, entries at `text-xs` on a `border-s-2` rail, and the
102
+ current one marked by turning that rail `border-primary` rather than by
103
+ growing.
104
+
105
+ The previous version gave the current page `text-primary font-medium` with
106
+ no size class, so it inherited the base size and rendered far larger than
107
+ its neighbours — the entry moved instead of being marked, and there was no
108
+ rail to show position at all. The two rails of this page are the same kind
109
+ of object and now read as one.
110
+ */
111
+ return (
112
+ <div className="flex flex-col gap-y-2">
113
+ <div className="flex items-center gap-2">
114
+ <BookOpenIcon className="text-muted-foreground h-4 w-4" />
115
+ <MicroLabel>{t("handbook.reader.contents")}</MicroLabel>
116
+ </div>
117
+ <nav aria-label={t("handbook.reader.contents")} className="flex flex-col gap-y-3">
118
+ {groups.map((group) => (
119
+ <div key={group.key} className="flex flex-col gap-y-0.5">
120
+ <MicroLabel className="ps-3">{group.title}</MicroLabel>
121
+ {group.pages.map((page) => {
122
+ const isCurrent = page.id === currentId;
123
+ return (
124
+ <Link
125
+ key={page.id}
126
+ ref={isCurrent ? currentRef : undefined}
127
+ href={generateUrl({ page: Modules.HandbookPage, id: page.id })}
128
+ aria-current={isCurrent ? "page" : undefined}
129
+ className={cn(
130
+ "hover:text-primary block border-s-2 py-1 ps-3 text-start text-xs leading-tight transition-colors",
131
+ isCurrent ? "border-primary text-primary font-medium" : "text-muted-foreground border-transparent",
132
+ )}
133
+ >
134
+ {handbookPageTitle(page)}
135
+ </Link>
136
+ );
137
+ })}
138
+ </div>
139
+ ))}
140
+ </nav>
141
+ </div>
142
+ );
143
+ }
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { createContext, ReactNode, useContext } from "react";
5
+ import { SharedProvider } from "../../../contexts";
6
+ import { Modules } from "../../../core";
7
+ import { usePageUrlGenerator } from "../../../hooks";
8
+ import { BreadcrumbItemData } from "../../../interfaces";
9
+ import type { HandbookPageInterface, HandbookSectionInterface } from "../data";
10
+ import { handbookPageTitle, handbookSectionTitle } from "../data/handbookDisplay";
11
+
12
+ /**
13
+ * The handbook's page context.
14
+ *
15
+ * The reader's header was an empty bar with a book icon in it: the feature
16
+ * shipped no provider, and `RoundPageContainerTitle` reads its heading from
17
+ * `useSharedContext().title`
18
+ * (components/containers/RoundPageContainerTitle.tsx). With nothing supplying
19
+ * that context there was no title, no breadcrumb and no way back — so the page
20
+ * title was printed a second time inside the body instead.
21
+ *
22
+ * This is the provider every other feature already has, copied from
23
+ * `RoleProvider` (features/role/contexts/RoleContext.tsx): a `breadcrumb()`, a
24
+ * `title()`, and `SharedProvider` wrapping the feature's own context. Every
25
+ * handbook surface — contents, reader, ask — mounts it, so all three get the
26
+ * same header for free.
27
+ */
28
+
29
+ interface HandbookContextType {
30
+ page: HandbookPageInterface | undefined;
31
+ section: HandbookSectionInterface | undefined;
32
+ }
33
+
34
+ const HandbookContext = createContext<HandbookContextType | undefined>(undefined);
35
+
36
+ type HandbookProviderProps = {
37
+ children: ReactNode;
38
+ /** Undefined on the contents and ask surfaces. */
39
+ page?: HandbookPageInterface;
40
+ /** The page's section, when one is loaded and resolvable. */
41
+ section?: HandbookSectionInterface;
42
+ /** Rendered into `title.functions`. */
43
+ functions?: ReactNode;
44
+ /** Overrides the default `title.type`. Used by the ask surface. */
45
+ titleType?: string;
46
+ };
47
+
48
+ export const HandbookProvider = ({ children, page, section, functions, titleType }: HandbookProviderProps) => {
49
+ const generateUrl = usePageUrlGenerator();
50
+ const t = useTranslations();
51
+
52
+ const breadcrumb = () => {
53
+ const response: BreadcrumbItemData[] = [];
54
+
55
+ response.push({
56
+ name: t(`handbook.title`),
57
+ href: generateUrl({ page: Modules.HandbookPage }),
58
+ });
59
+
60
+ if (page)
61
+ response.push({
62
+ name: handbookPageTitle(page),
63
+ href: generateUrl({ page: Modules.HandbookPage, id: page.id }),
64
+ });
65
+
66
+ return response;
67
+ };
68
+
69
+ const title = () => {
70
+ const response: any = {
71
+ // The reader is titled by where the page sits, not by the manual it
72
+ // belongs to: the section is the `type` and the page is the `element`,
73
+ // the same split every entity detail page in the product uses. Sections
74
+ // are loaded as their own resource, so a page whose section has not
75
+ // resolved falls back to the raw key it carries — and the key is the
76
+ // directory name, which is not translated and never was.
77
+ type: titleType ?? (page ? (section ? handbookSectionTitle(section) : page.section) : t(`handbook.title`)),
78
+ };
79
+
80
+ if (page) {
81
+ response.element = handbookPageTitle(page);
82
+ }
83
+
84
+ if (functions) response.functions = functions;
85
+
86
+ return response;
87
+ };
88
+
89
+ return (
90
+ <SharedProvider value={{ breadcrumbs: breadcrumb(), title: title() }}>
91
+ <HandbookContext.Provider
92
+ value={{
93
+ page: page,
94
+ section: section,
95
+ }}
96
+ >
97
+ {children}
98
+ </HandbookContext.Provider>
99
+ </SharedProvider>
100
+ );
101
+ };
102
+
103
+ export const useHandbookContext = (): HandbookContextType => {
104
+ const context = useContext(HandbookContext);
105
+ if (context === undefined) {
106
+ throw new Error("useHandbookContext must be used within a HandbookProvider");
107
+ }
108
+ return context;
109
+ };
@@ -0,0 +1,131 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { beforeAll, describe, expect, it, vi } from "vitest";
3
+
4
+ import { useSharedContext } from "../../../../contexts";
5
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
6
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
7
+ import { HandbookProvider, useHandbookContext } from "../HandbookContext";
8
+
9
+ vi.mock("next-intl", () => ({
10
+ useTranslations: () => (key: string) => key,
11
+ }));
12
+
13
+ // The provider builds its breadcrumbs with `generateUrl({ page: Modules.HandbookPage })`,
14
+ // and the `Modules` proxy throws for a key the registry does not hold. Registering is the
15
+ // package's spec convention — copied from HandbookAskContainer.spec.tsx, which registers
16
+ // the same module for the same reason.
17
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
18
+ try {
19
+ ModuleRegistry.get(key as any);
20
+ } catch {
21
+ ModuleRegistry.register(key, module);
22
+ }
23
+ };
24
+
25
+ beforeAll(() => {
26
+ registerIfAbsent("HandbookPage", { name: "handbookpages", pageUrl: "/administration/handbook" } as any);
27
+ });
28
+
29
+ function Probe() {
30
+ const { title, breadcrumbs } = useSharedContext();
31
+ return (
32
+ <>
33
+ <span data-testid="type">{String(title.type)}</span>
34
+ <span data-testid="element">{title.element ?? ""}</span>
35
+ <span data-testid="crumbs">{breadcrumbs.map((b) => b.name).join(" / ")}</span>
36
+ </>
37
+ );
38
+ }
39
+
40
+ describe("HandbookProvider", () => {
41
+ it("titles the contents surface with the handbook title and no element", () => {
42
+ render(
43
+ <HandbookProvider>
44
+ <Probe />
45
+ </HandbookProvider>,
46
+ );
47
+ expect(screen.getByTestId("type")).toHaveTextContent("handbook.title");
48
+ expect(screen.getByTestId("element")).toHaveTextContent("");
49
+ });
50
+
51
+ it("titles the reader with the section as type and the page as element", () => {
52
+ render(
53
+ <HandbookProvider
54
+ page={{ id: "p1", title: "Testing the API", section: "03-backend" } as any}
55
+ section={{ key: "03-backend", title: "Backend" } as any}
56
+ >
57
+ <Probe />
58
+ </HandbookProvider>,
59
+ );
60
+ expect(screen.getByTestId("type")).toHaveTextContent("Backend");
61
+ expect(screen.getByTestId("element")).toHaveTextContent("Testing the API");
62
+ expect(screen.getByTestId("crumbs")).toHaveTextContent("handbook.title / Testing the API");
63
+ });
64
+
65
+ it("falls back to the raw section key when no section resource resolved", () => {
66
+ render(
67
+ <HandbookProvider page={{ id: "p1", title: "X", section: "09-workflow" } as any}>
68
+ <Probe />
69
+ </HandbookProvider>,
70
+ );
71
+ expect(screen.getByTestId("type")).toHaveTextContent("09-workflow");
72
+ });
73
+
74
+ it("lets the ask surface override the type and passes functions through", () => {
75
+ function FunctionsProbe() {
76
+ const { title } = useSharedContext();
77
+ return <span data-testid="functions">{title.functions}</span>;
78
+ }
79
+
80
+ render(
81
+ <HandbookProvider titleType="handbook.chat.title" functions={<button type="button">ask</button>}>
82
+ <FunctionsProbe />
83
+ </HandbookProvider>,
84
+ );
85
+ expect(screen.getByTestId("functions")).toHaveTextContent("ask");
86
+ });
87
+
88
+ it("titles the reader with the translation when the page and section carry one", () => {
89
+ render(
90
+ <HandbookProvider
91
+ page={{ id: "p1", title: "Testing the API", displayTitle: "Testare le API", section: "03-backend" } as any}
92
+ section={{ key: "03-backend", title: "Backend", displayTitle: "Backend (API)" } as any}
93
+ >
94
+ <Probe />
95
+ </HandbookProvider>,
96
+ );
97
+ expect(screen.getByTestId("type")).toHaveTextContent("Backend (API)");
98
+ expect(screen.getByTestId("element")).toHaveTextContent("Testare le API");
99
+ expect(screen.getByTestId("crumbs")).toHaveTextContent("handbook.title / Testare le API");
100
+ });
101
+
102
+ it("falls back to the English title when neither carries a translation", () => {
103
+ render(
104
+ <HandbookProvider
105
+ page={{ id: "p1", title: "Testing the API", displayTitle: undefined, section: "03-backend" } as any}
106
+ section={{ key: "03-backend", title: "Backend", displayTitle: undefined } as any}
107
+ >
108
+ <Probe />
109
+ </HandbookProvider>,
110
+ );
111
+ expect(screen.getByTestId("type")).toHaveTextContent("Backend");
112
+ expect(screen.getByTestId("element")).toHaveTextContent("Testing the API");
113
+ });
114
+
115
+ it("exposes the page and section to useHandbookContext", () => {
116
+ function ContextProbe() {
117
+ const { page, section } = useHandbookContext();
118
+ return <span data-testid="context">{`${page?.title} / ${section?.title}`}</span>;
119
+ }
120
+
121
+ render(
122
+ <HandbookProvider
123
+ page={{ id: "p1", title: "Testing the API", section: "03-backend" } as any}
124
+ section={{ key: "03-backend", title: "Backend" } as any}
125
+ >
126
+ <ContextProbe />
127
+ </HandbookProvider>,
128
+ );
129
+ expect(screen.getByTestId("context")).toHaveTextContent("Testing the API / Backend");
130
+ });
131
+ });
@@ -0,0 +1,136 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+ import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
3
+ import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
4
+ import { HandbookPage } from "./HandbookPage";
5
+
6
+ // createJsonApi reads Modules.HandbookPage.name (resolved lazily via
7
+ // ModuleRegistry), so the module must be registered before the model
8
+ // serialises — mirrors the HowTo.spec setup convention in this package.
9
+ const handbookPageModule: ApiRequestDataTypeInterface = {
10
+ name: "handbookpages",
11
+ model: HandbookPage,
12
+ } as any;
13
+
14
+ beforeAll(() => {
15
+ ModuleRegistry.register("HandbookPage", handbookPageModule);
16
+ });
17
+
18
+ describe("HandbookPage", () => {
19
+ it("rehydrates every attribute", () => {
20
+ const page = new HandbookPage().rehydrate({
21
+ jsonApi: {
22
+ id: "id-1",
23
+ type: "handbookpages",
24
+ attributes: {
25
+ path: "03-backend/module-anatomy.md",
26
+ title: "Module anatomy",
27
+ content: "# Module anatomy",
28
+ contentHash: "abc",
29
+ wordCount: 3,
30
+ aiStatus: "completed",
31
+ },
32
+ },
33
+ } as any);
34
+
35
+ expect(page.path).toBe("03-backend/module-anatomy.md");
36
+ expect(page.title).toBe("Module anatomy");
37
+ expect(page.content).toBe("# Module anatomy");
38
+ expect(page.contentHash).toBe("abc");
39
+ expect(page.wordCount).toBe(3);
40
+ expect(page.aiStatus).toBe("completed");
41
+ });
42
+
43
+ it("defaults a missing word count to zero and a missing status to undefined", () => {
44
+ const page = new HandbookPage().rehydrate({
45
+ jsonApi: {
46
+ id: "id-1",
47
+ type: "handbookpages",
48
+ attributes: { path: "a.md", title: "A", content: "#A", contentHash: "h" },
49
+ },
50
+ } as any);
51
+
52
+ expect(page.wordCount).toBe(0);
53
+ expect(page.aiStatus).toBeUndefined();
54
+ });
55
+
56
+ it("creates an identity-only JSON:API payload", () => {
57
+ const payload = new HandbookPage().createJsonApi({ id: "id-1" });
58
+ expect(payload.data.type).toBe("handbookpages");
59
+ expect(payload.data.id).toBe("id-1");
60
+ expect(payload.data.attributes).toEqual({});
61
+ });
62
+
63
+ it("rehydrates section, order and summary", () => {
64
+ const page = new HandbookPage().rehydrate({
65
+ jsonApi: {
66
+ type: "handbookpages",
67
+ id: "p1",
68
+ attributes: {
69
+ path: "03-backend/testing.md",
70
+ title: "Testing the API",
71
+ section: "03-backend",
72
+ order: "03-backend/testing.md",
73
+ summary: "The spec layout and what is mocked.",
74
+ },
75
+ },
76
+ included: [],
77
+ } as any);
78
+
79
+ expect(page.section).toBe("03-backend");
80
+ expect(page.order).toBe("03-backend/testing.md");
81
+ expect(page.summary).toBe("The spec layout and what is mocked.");
82
+ });
83
+
84
+ it("returns undefined for a missing summary", () => {
85
+ const page = new HandbookPage().rehydrate({
86
+ jsonApi: { type: "handbookpages", id: "p1", attributes: { title: "X" } },
87
+ included: [],
88
+ } as any);
89
+ expect(page.summary).toBeUndefined();
90
+ });
91
+
92
+ it("rehydrates the translation that travels beside the English original", () => {
93
+ const page = new HandbookPage().rehydrate({
94
+ jsonApi: {
95
+ type: "handbookpages",
96
+ id: "p1",
97
+ attributes: {
98
+ path: "03-backend/testing.md",
99
+ title: "Testing the API",
100
+ summary: "The spec layout and what is mocked.",
101
+ content: "# Testing the API",
102
+ displayTitle: "Testare le API",
103
+ displaySummary: "Come sono organizzati gli spec.",
104
+ displayContent: "# Testare le API",
105
+ },
106
+ },
107
+ included: [],
108
+ } as any);
109
+
110
+ // The translation lives on the SAME node, matched by `path` at ingest
111
+ // time: no second id, no locale in the payload.
112
+ expect(page.id).toBe("p1");
113
+ expect(page.displayTitle).toBe("Testare le API");
114
+ expect(page.displaySummary).toBe("Come sono organizzati gli spec.");
115
+ expect(page.displayContent).toBe("# Testare le API");
116
+ // The English original is still there, and `path` is untouched: ordering
117
+ // and grouping read it and must not move.
118
+ expect(page.title).toBe("Testing the API");
119
+ expect(page.path).toBe("03-backend/testing.md");
120
+ });
121
+
122
+ it("leaves an untranslated page's display fields undefined", () => {
123
+ const page = new HandbookPage().rehydrate({
124
+ jsonApi: {
125
+ type: "handbookpages",
126
+ id: "p1",
127
+ attributes: { title: "Testing the API", summary: "S", content: "# C" },
128
+ },
129
+ included: [],
130
+ } as any);
131
+
132
+ expect(page.displayTitle).toBeUndefined();
133
+ expect(page.displaySummary).toBeUndefined();
134
+ expect(page.displayContent).toBeUndefined();
135
+ });
136
+ });
@@ -0,0 +1,99 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { HandbookPageInput } from "./HandbookPageInput";
3
+ import { HandbookPageInterface } from "./HandbookPageInterface";
4
+
5
+ export class HandbookPage extends AbstractApiData implements HandbookPageInterface {
6
+ private _path?: string;
7
+ private _title?: string;
8
+ private _content?: string;
9
+ private _contentHash?: string;
10
+ private _wordCount?: number;
11
+ private _aiStatus?: string;
12
+ private _section?: string;
13
+ private _order?: string;
14
+ private _summary?: string;
15
+ private _displayTitle?: string;
16
+ private _displaySummary?: string;
17
+ private _displayContent?: string;
18
+
19
+ get path(): string {
20
+ return this._path ?? "";
21
+ }
22
+
23
+ get title(): string {
24
+ return this._title ?? "";
25
+ }
26
+
27
+ get content(): string {
28
+ return this._content ?? "";
29
+ }
30
+
31
+ get contentHash(): string {
32
+ return this._contentHash ?? "";
33
+ }
34
+
35
+ get wordCount(): number {
36
+ return this._wordCount ?? 0;
37
+ }
38
+
39
+ get aiStatus(): string | undefined {
40
+ return this._aiStatus;
41
+ }
42
+
43
+ get section(): string {
44
+ return this._section ?? "";
45
+ }
46
+
47
+ get order(): string {
48
+ return this._order ?? "";
49
+ }
50
+
51
+ get summary(): string | undefined {
52
+ return this._summary;
53
+ }
54
+
55
+ get displayTitle(): string | undefined {
56
+ return this._displayTitle;
57
+ }
58
+
59
+ get displaySummary(): string | undefined {
60
+ return this._displaySummary;
61
+ }
62
+
63
+ get displayContent(): string | undefined {
64
+ return this._displayContent;
65
+ }
66
+
67
+ rehydrate(data: JsonApiHydratedDataInterface): this {
68
+ super.rehydrate(data);
69
+
70
+ this._path = data.jsonApi.attributes.path;
71
+ this._title = data.jsonApi.attributes.title;
72
+ this._content = data.jsonApi.attributes.content;
73
+ this._contentHash = data.jsonApi.attributes.contentHash;
74
+ this._wordCount = data.jsonApi.attributes.wordCount;
75
+ this._aiStatus = data.jsonApi.attributes.aiStatus;
76
+ this._section = data.jsonApi.attributes.section;
77
+ this._order = data.jsonApi.attributes.order;
78
+ this._summary = data.jsonApi.attributes.summary;
79
+ this._displayTitle = data.jsonApi.attributes.displayTitle;
80
+ this._displaySummary = data.jsonApi.attributes.displaySummary;
81
+ this._displayContent = data.jsonApi.attributes.displayContent;
82
+
83
+ return this;
84
+ }
85
+
86
+ createJsonApi(data: HandbookPageInput) {
87
+ const response: any = {
88
+ data: {
89
+ type: Modules.HandbookPage.name,
90
+ id: data.id,
91
+ attributes: {},
92
+ relationships: {},
93
+ },
94
+ included: [],
95
+ };
96
+
97
+ return response;
98
+ }
99
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * An ENUM, not a string array: `ContentListTable` indexes its column map by
3
+ * these members and `useDataListRetriever` keys rows by the id member, exactly
4
+ * as HowToFields does.
5
+ *
6
+ * `content` and `contentHash` are deliberately absent: they are detail-only,
7
+ * and a list of a thousand markdown files must not carry their bodies.
8
+ * `displayContent` — the same body in the reader's language — carries the same
9
+ * caveat: it names the field for the detail read and is deliberately NOT in the
10
+ * module's list inclusion.
11
+ *
12
+ * `section`, `order` and `summary` are list-safe for the opposite reason: they
13
+ * are the index fields the contents page groups, orders and describes pages by,
14
+ * so a list that omitted them could not render a table of contents at all.
15
+ * `displayTitle` and `displaySummary` travel with them: the manual is indexed
16
+ * in English and shown translated, so the contents page needs both halves.
17
+ */
18
+ export enum HandbookPageFields {
19
+ handbookPageId = "handbookPageId",
20
+
21
+ path = "path",
22
+ title = "title",
23
+ displayTitle = "displayTitle",
24
+ section = "section",
25
+ order = "order",
26
+ summary = "summary",
27
+ displaySummary = "displaySummary",
28
+ displayContent = "displayContent",
29
+ wordCount = "wordCount",
30
+ aiStatus = "aiStatus",
31
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Handbook pages are created by the backend ingest from files, never by a
3
+ * client. The input therefore carries identity only — it exists so the model
4
+ * satisfies AbstractApiData's contract, not because anything writes attributes.
5
+ */
6
+ export type HandbookPageInput = {
7
+ id: string;
8
+ };
@@ -0,0 +1,29 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+
3
+ export interface HandbookPageInterface extends ApiDataInterface {
4
+ get path(): string;
5
+ get title(): string;
6
+ get content(): string;
7
+ get contentHash(): string;
8
+ get wordCount(): number;
9
+ get aiStatus(): string | undefined;
10
+ /** The top-level directory of the documentation tree this page belongs to. */
11
+ get section(): string;
12
+ /** The repo-relative path; sorting on it reproduces filesystem order. */
13
+ get order(): string;
14
+ /** The one-line description the tree's README index gives this page, when it has one. */
15
+ get summary(): string | undefined;
16
+ /**
17
+ * The translated rendering of `title`, when the ingest matched a translation
18
+ * of this page's `path`. The manual is INDEXED in English and SHOWN in the
19
+ * reader's language, so every surface renders `displayTitle ?? title`.
20
+ */
21
+ get displayTitle(): string | undefined;
22
+ /** The translated `summary`. Absent when the page has no translation. */
23
+ get displaySummary(): string | undefined;
24
+ /**
25
+ * The translated body. Detail-only, exactly as `content` is: it is the whole
26
+ * markdown file, so it never travels in a list payload.
27
+ */
28
+ get displayContent(): string | undefined;
29
+ }