@anthusai/papyrus 0.0.0 → 1.0.0-next.2

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 (254) hide show
  1. package/LICENSE +21 -0
  2. package/amplify/auth/resource.js +61 -0
  3. package/amplify/auth/resource.ts +65 -0
  4. package/amplify/data/resource.js +2037 -0
  5. package/amplify/data/resource.ts +2148 -0
  6. package/amplify/data/schema.js +1850 -0
  7. package/amplify/data/schema.ts +1953 -0
  8. package/amplify/functions/assignment-action/handler.ts +1416 -0
  9. package/amplify/functions/assignment-action/resource.js +8 -0
  10. package/amplify/functions/assignment-action/resource.ts +9 -0
  11. package/amplify/functions/category-action/handler.ts +2259 -0
  12. package/amplify/functions/category-action/resource.js +8 -0
  13. package/amplify/functions/category-action/resource.ts +9 -0
  14. package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
  15. package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
  16. package/amplify/functions/console-chat-responder/Dockerfile +19 -0
  17. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
  18. package/amplify/functions/console-chat-responder/resource.js +153 -0
  19. package/amplify/functions/console-chat-responder/resource.ts +179 -0
  20. package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
  21. package/amplify/functions/email-submission-processor/handler.py +46 -0
  22. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  23. package/amplify/functions/email-submission-processor/resource.js +56 -0
  24. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  25. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  26. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  27. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  28. package/amplify/functions/knowledge-query/handler.py +1 -0
  29. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  30. package/amplify/functions/knowledge-query/resource.js +50 -0
  31. package/amplify/functions/knowledge-query/resource.ts +61 -0
  32. package/amplify/functions/manage-user-role/handler.ts +799 -0
  33. package/amplify/functions/manage-user-role/resource.js +8 -0
  34. package/amplify/functions/manage-user-role/resource.ts +9 -0
  35. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  36. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  37. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  38. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  39. package/amplify/functions/newsroom-summary/resource.js +8 -0
  40. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  41. package/amplify/functions/procedure-action/handler.ts +621 -0
  42. package/amplify/functions/procedure-action/resource.js +8 -0
  43. package/amplify/functions/procedure-action/resource.ts +9 -0
  44. package/amplify/functions/reader-settings/handler.ts +322 -0
  45. package/amplify/functions/reader-settings/resource.js +8 -0
  46. package/amplify/functions/reader-settings/resource.ts +9 -0
  47. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  48. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  49. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  50. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  51. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  52. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  53. package/amplify/functions/shared/email-submission.ts +415 -0
  54. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  55. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  56. package/amplify/functions/shared/model-payloads.ts +186 -0
  57. package/amplify/functions/shared/python-bundle.js +115 -0
  58. package/amplify/functions/shared/python-bundle.ts +141 -0
  59. package/amplify/functions/shared/slack-events.ts +111 -0
  60. package/amplify/functions/slack-delivery/handler.py +112 -0
  61. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  62. package/amplify/functions/slack-delivery/resource.js +38 -0
  63. package/amplify/functions/slack-delivery/resource.ts +51 -0
  64. package/amplify/functions/slack-events/handler.ts +155 -0
  65. package/amplify/functions/slack-events/resource.js +11 -0
  66. package/amplify/functions/slack-events/resource.ts +12 -0
  67. package/amplify/inbound-email/stack.js +46 -0
  68. package/amplify/inbound-email/stack.ts +61 -0
  69. package/amplify/package.json +3 -0
  70. package/amplify/site-backend-config.js +1 -0
  71. package/amplify/site-backend-config.ts +30 -0
  72. package/amplify/site-backend.js +587 -0
  73. package/amplify/site-backend.ts +704 -0
  74. package/amplify/storage/resource.js +18 -0
  75. package/amplify/storage/resource.ts +19 -0
  76. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  77. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  78. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  79. package/app/[year]/[month]/[day]/page.tsx +22 -0
  80. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  81. package/app/api/archive/editions/route.ts +18 -0
  82. package/app/api/media/[...path]/route.ts +35 -0
  83. package/app/api/revalidate/route.ts +94 -0
  84. package/app/archive/page.tsx +24 -0
  85. package/app/articles/[slug]/page.tsx +35 -0
  86. package/app/globals.css +10329 -0
  87. package/app/layout.tsx +146 -0
  88. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  89. package/app/newsroom/[section]/page.tsx +47 -0
  90. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  91. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  92. package/app/newsroom/insights/page.tsx +14 -0
  93. package/app/newsroom/layout.tsx +12 -0
  94. package/app/newsroom/page.tsx +31 -0
  95. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  96. package/app/page.tsx +272 -0
  97. package/app/settings/page.tsx +14 -0
  98. package/app/tailwind.css +65 -0
  99. package/bin/papyrus-app.mjs +10 -0
  100. package/bin/papyrus-infra.mjs +39 -0
  101. package/components/ai-elements/conversation.tsx +168 -0
  102. package/components/ai-elements/message.tsx +360 -0
  103. package/components/ai-elements/model-selector.tsx +30 -0
  104. package/components/ai-elements/prompt-input.tsx +1461 -0
  105. package/components/ai-elements/shimmer.tsx +76 -0
  106. package/components/ai-elements/suggestion.tsx +54 -0
  107. package/components/ai-elements/tool.tsx +173 -0
  108. package/components/amplify-client-provider.tsx +140 -0
  109. package/components/archive-grid.tsx +89 -0
  110. package/components/archive-shell.tsx +41 -0
  111. package/components/article-video.tsx +47 -0
  112. package/components/blog-page-background.tsx +20 -0
  113. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  114. package/components/news-desk-client-provider.tsx +211 -0
  115. package/components/news-desk-page.tsx +119 -0
  116. package/components/news-desk-taxonomy-client.ts +3684 -0
  117. package/components/newspaper.tsx +1929 -0
  118. package/components/newsroom-assignments-view.tsx +664 -0
  119. package/components/newsroom-ops-overview.tsx +226 -0
  120. package/components/newsroom-ops-shell.tsx +319 -0
  121. package/components/newsroom-references-view.tsx +598 -0
  122. package/components/newsroom-topics-view.tsx +425 -0
  123. package/components/papyrus-console-shell.tsx +1912 -0
  124. package/components/pictogram-figure.tsx +65 -0
  125. package/components/presentation-footer.tsx +146 -0
  126. package/components/reader-auth-control.tsx +162 -0
  127. package/components/reader-auth-state.ts +122 -0
  128. package/components/reader-settings.ts +332 -0
  129. package/components/reader-theme-control.tsx +85 -0
  130. package/components/reference-source-preview.tsx +163 -0
  131. package/components/settings-page.tsx +239 -0
  132. package/components/topic-steering-workspace.tsx +18063 -0
  133. package/components/ui/badge.tsx +30 -0
  134. package/components/ui/button-group.tsx +87 -0
  135. package/components/ui/button.tsx +58 -0
  136. package/components/ui/card.tsx +32 -0
  137. package/components/ui/command.tsx +194 -0
  138. package/components/ui/dialog.tsx +158 -0
  139. package/components/ui/dropdown-menu.tsx +266 -0
  140. package/components/ui/hover-card.tsx +51 -0
  141. package/components/ui/input-group.tsx +158 -0
  142. package/components/ui/input.tsx +20 -0
  143. package/components/ui/scroll-area.tsx +16 -0
  144. package/components/ui/select.tsx +199 -0
  145. package/components/ui/separator.tsx +23 -0
  146. package/components/ui/sheet.tsx +92 -0
  147. package/components/ui/spinner.tsx +10 -0
  148. package/components/ui/tabs.tsx +113 -0
  149. package/components/ui/textarea.tsx +18 -0
  150. package/components/ui/tooltip.tsx +66 -0
  151. package/components/use-resolved-papyrus-theme.ts +47 -0
  152. package/infra/amplify-app-shell.js +109 -0
  153. package/infra/amplify-app-shell.ts +155 -0
  154. package/infra/index.js +12 -0
  155. package/lib/amplify-outputs-guard.ts +24 -0
  156. package/lib/amplify-outputs-path.ts +16 -0
  157. package/lib/amplify-server-runtime.ts +63 -0
  158. package/lib/archive-data.ts +30 -0
  159. package/lib/archive-types.ts +15 -0
  160. package/lib/articles.ts +320 -0
  161. package/lib/cached-content-repository.ts +89 -0
  162. package/lib/category-dashboard.ts +37 -0
  163. package/lib/category-repository.ts +1907 -0
  164. package/lib/console-chat-client.ts +1316 -0
  165. package/lib/console-web-context.ts +118 -0
  166. package/lib/content-load-timing.ts +23 -0
  167. package/lib/content-repository.ts +40 -0
  168. package/lib/content-types.ts +104 -0
  169. package/lib/define-site.js +3 -0
  170. package/lib/define-site.ts +21 -0
  171. package/lib/demo-amplify-outputs.ts +15 -0
  172. package/lib/dev-sandbox-editor-auth.ts +18 -0
  173. package/lib/doctrine.ts +128 -0
  174. package/lib/edition-routes.ts +155 -0
  175. package/lib/edition-sections.ts +111 -0
  176. package/lib/empty-edition-layout-plan.ts +109 -0
  177. package/lib/excerpts.ts +8 -0
  178. package/lib/graphql-content-repository.ts +986 -0
  179. package/lib/image-url.ts +10 -0
  180. package/lib/layout-plan.ts +981 -0
  181. package/lib/layout-scenarios.ts +564 -0
  182. package/lib/markus-body.ts +168 -0
  183. package/lib/markus-ir.ts +235 -0
  184. package/lib/markus-projection.ts +243 -0
  185. package/lib/markus-to-article.ts +93 -0
  186. package/lib/news-desk-session.ts +160 -0
  187. package/lib/newspaper-layout.ts +3459 -0
  188. package/lib/newsroom-assignments.ts +173 -0
  189. package/lib/newsroom-base-path.ts +48 -0
  190. package/lib/newsroom-card-layout.ts +102 -0
  191. package/lib/newsroom-category-drilldown.ts +133 -0
  192. package/lib/newsroom-demo-dashboard.ts +267 -0
  193. package/lib/newsroom-demo-profile.ts +46 -0
  194. package/lib/newsroom-forum-routes.ts +153 -0
  195. package/lib/newsroom-index-filters.ts +202 -0
  196. package/lib/newsroom-knowledge-query-request.ts +51 -0
  197. package/lib/newsroom-list-selection.ts +42 -0
  198. package/lib/newsroom-nav.ts +42 -0
  199. package/lib/newsroom-references.ts +116 -0
  200. package/lib/newsroom-sections.ts +52 -0
  201. package/lib/newsroom-topics.ts +118 -0
  202. package/lib/papyrus-object-kinds.ts +24 -0
  203. package/lib/papyrus-web-locations.ts +480 -0
  204. package/lib/pilobol-sample.ts +167 -0
  205. package/lib/presentation-footer.ts +92 -0
  206. package/lib/pretext-layout.ts +179 -0
  207. package/lib/public-placeholder-config.ts +102 -0
  208. package/lib/publication-items.ts +181 -0
  209. package/lib/reader-route-config.ts +1 -0
  210. package/lib/reader-static-params.ts +56 -0
  211. package/lib/reader-storage-url.ts +63 -0
  212. package/lib/reference-display.ts +74 -0
  213. package/lib/reference-policy.ts +97 -0
  214. package/lib/reference-source-preview.ts +175 -0
  215. package/lib/renderer-config.ts +50 -0
  216. package/lib/renderer.ts +87 -0
  217. package/lib/reporting-story-budget.ts +638 -0
  218. package/lib/semantic-graph.ts +554 -0
  219. package/lib/site-brand-tenant.ts +41 -0
  220. package/lib/site-brand.ts +179 -0
  221. package/lib/site-renderer.ts +17 -0
  222. package/lib/site-stack.ts +195 -0
  223. package/lib/themed-image.ts +30 -0
  224. package/lib/utils.ts +6 -0
  225. package/middleware.ts +78 -0
  226. package/package.json +296 -1
  227. package/publications/anth_us/brand.ts +68 -0
  228. package/publications/anth_us/theme.css +66 -0
  229. package/publications/papyrus/theme.css +11 -0
  230. package/publications/pilobol_us/brand.ts +37 -0
  231. package/publications/pilobol_us/theme.css +140 -0
  232. package/publications/threat_intelligence/blog-defense/graph.ts +636 -0
  233. package/publications/threat_intelligence/blog-defense/layout.ts +934 -0
  234. package/publications/threat_intelligence/blog-defense/page-background.tsx +590 -0
  235. package/publications/threat_intelligence/brand.ts +40 -0
  236. package/publications/threat_intelligence/pictograms/art.tsx +608 -0
  237. package/publications/threat_intelligence/pictograms/figure.tsx +82 -0
  238. package/publications/threat_intelligence/pictograms/registry.ts +30 -0
  239. package/publications/threat_intelligence/pictograms/system.tsx +305 -0
  240. package/publications/threat_intelligence/seed/seed-edition-content.json +629 -0
  241. package/publications/threat_intelligence/theme.css +1440 -0
  242. package/renderers/markus/index.ts +25 -0
  243. package/renderers/markus/stubs.tsx +33 -0
  244. package/renderers/pretext/article-page.tsx +174 -0
  245. package/renderers/pretext/index.ts +30 -0
  246. package/renderers/pretext/layouts.ts +16 -0
  247. package/renderers/pretext/presentation-shell.tsx +477 -0
  248. package/routes.manifest.json +182 -0
  249. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  250. package/src/backend.js +1 -0
  251. package/src/backend.ts +3 -0
  252. package/src/sync.mjs +87 -0
  253. package/src/with-papyrus.d.mts +2 -0
  254. package/src/with-papyrus.mjs +47 -0
@@ -0,0 +1,1929 @@
1
+ "use client";
2
+
3
+ import { gsap } from "gsap";
4
+ import { ScrollToPlugin } from "gsap/ScrollToPlugin";
5
+ import { Hub } from "aws-amplify/utils";
6
+ import Image from "next/image";
7
+ import Link from "next/link";
8
+ import { usePathname } from "next/navigation";
9
+ import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react";
10
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11
+ import { ARCHIVE_PREVIEW_HEIGHT, ARCHIVE_PREVIEW_WIDTH } from "../lib/archive-types";
12
+ import type { EditionContent, NewsDeskAppendix, NewsDeskCategoryTreeNode } from "../lib/content-types";
13
+ import { shouldBypassImageOptimization } from "../lib/image-url";
14
+ import type { PublicationItem } from "../lib/publication-items";
15
+ import { SITE_BRAND } from "../lib/site-brand";
16
+ import { resolveThemedImageSrc, type ResolvedTheme } from "../lib/themed-image";
17
+ import { loadEditorCategoryTreeState } from "./news-desk-taxonomy-client";
18
+ import { ReaderAuthControl } from "./reader-auth-control";
19
+ import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
20
+ import {
21
+ buildNewspaperLayout,
22
+ type NewspaperLayout,
23
+ type SolvedBlock,
24
+ type SolvedChromeBox,
25
+ type SolvedFrontFooter,
26
+ type SolvedFurniture,
27
+ type SolvedImageFurniture,
28
+ type SolvedPage,
29
+ type SolvedPullQuoteFurniture,
30
+ type SolvedRegion,
31
+ type TextLine,
32
+ } from "../lib/newspaper-layout";
33
+
34
+ gsap.registerPlugin(ScrollToPlugin);
35
+
36
+ type BookMetrics = {
37
+ pageWidth: number;
38
+ viewportHeight: number;
39
+ };
40
+
41
+ type ScrollToPageOptions = {
42
+ immediate?: boolean;
43
+ history?: "push" | "replace" | "none";
44
+ };
45
+
46
+ type PapyrusTestWindow = Window & typeof globalThis & {
47
+ __PAPYRUS_LAYOUT__?: NewspaperLayout;
48
+ __PAPYRUS_TOTAL_PAGES__?: number;
49
+ __PAPYRUS_SCENARIO__?: string;
50
+ __PAPYRUS_VISIBLE_PAGE__?: number;
51
+ __PAPYRUS_SCROLL_TO_PAGE__?: (pageNumber: number, options?: ScrollToPageOptions) => void;
52
+ };
53
+
54
+ type NewspaperProps = {
55
+ content: EditionContent;
56
+ editionBasePath?: string;
57
+ mastheadHomeHref?: string;
58
+ initialPageNumber?: number;
59
+ initialSectionKey?: string;
60
+ preserveContentLocation?: boolean;
61
+ };
62
+
63
+ const PAGE_LOOKAROUND = 1;
64
+
65
+ type NewsDeskAppendixPage = {
66
+ id: string;
67
+ pageNumber: number;
68
+ mode: "register" | "category";
69
+ appendix: NewsDeskAppendix;
70
+ roots: NewsDeskCategoryTreeNode[];
71
+ root?: NewsDeskCategoryTreeNode;
72
+ subcategories: NewsDeskCategoryTreeNode[];
73
+ };
74
+
75
+ export function Newspaper({
76
+ content,
77
+ editionBasePath,
78
+ mastheadHomeHref,
79
+ initialPageNumber = 1,
80
+ initialSectionKey,
81
+ preserveContentLocation = false,
82
+ }: NewspaperProps) {
83
+ const resolvedTheme = useResolvedPapyrusTheme();
84
+ const pathname = usePathname();
85
+ const normalizedInitialPage = Math.max(1, Math.floor(initialPageNumber));
86
+ const shellRef = useRef<HTMLDivElement | null>(null);
87
+ const progressRef = useRef<HTMLElement | null>(null);
88
+ const pageRefs = useRef<Map<number, HTMLElement>>(new Map());
89
+ const hasInitializedLocationRef = useRef(false);
90
+ const pendingProgrammaticPageRef = useRef<number | null>(null);
91
+ const preservedItemHashPageRef = useRef<number | null>(null);
92
+ const [metrics, setMetrics] = useState<BookMetrics | null>(null);
93
+ const [fontRevision, setFontRevision] = useState(0);
94
+ const [visiblePage, setVisiblePage] = useState(normalizedInitialPage);
95
+ const [priorityPages, setPriorityPages] = useState<Set<number>>(new Set([normalizedInitialPage]));
96
+ const [showRhythmOverlay, setShowRhythmOverlay] = useState(false);
97
+ const [editorAppendix, setEditorAppendix] = useState<NewsDeskAppendix | null>(null);
98
+ const [editorAppendixReady, setEditorAppendixReady] = useState(false);
99
+
100
+ useEffect(() => {
101
+ const node = shellRef.current;
102
+ if (!node) return;
103
+
104
+ const updateMetrics = (width: number) => {
105
+ const pageWidth = Math.max(320, Math.round(width));
106
+ const viewportHeight = window.innerHeight || 900;
107
+ setMetrics((previous) => {
108
+ if (previous?.pageWidth === pageWidth && previous.viewportHeight === viewportHeight) return previous;
109
+ return { pageWidth, viewportHeight };
110
+ });
111
+ };
112
+ const updateFromNode = () => updateMetrics(node.getBoundingClientRect().width);
113
+
114
+ const observer = new ResizeObserver((entries) => {
115
+ const entry = entries[0];
116
+ if (!entry) return;
117
+ updateMetrics(entry.contentRect.width);
118
+ });
119
+ observer.observe(node);
120
+ window.addEventListener("resize", updateFromNode);
121
+ updateFromNode();
122
+ return () => {
123
+ observer.disconnect();
124
+ window.removeEventListener("resize", updateFromNode);
125
+ };
126
+ }, []);
127
+
128
+ useEffect(() => {
129
+ const fonts = document.fonts;
130
+ if (!fonts) return;
131
+ let cancelled = false;
132
+ const refreshAfterFontLoad = () => {
133
+ if (!cancelled) setFontRevision((revision) => revision + 1);
134
+ };
135
+ fonts.ready.then(refreshAfterFontLoad).catch(() => undefined);
136
+ fonts.addEventListener("loadingdone", refreshAfterFontLoad);
137
+ fonts.addEventListener("loadingerror", refreshAfterFontLoad);
138
+ return () => {
139
+ cancelled = true;
140
+ fonts.removeEventListener("loadingdone", refreshAfterFontLoad);
141
+ fonts.removeEventListener("loadingerror", refreshAfterFontLoad);
142
+ };
143
+ }, []);
144
+
145
+ const layout = useMemo(() => {
146
+ void fontRevision;
147
+ return metrics === null ? null : buildNewspaperLayout(content.items, metrics.pageWidth, metrics.viewportHeight, content.layoutPlan);
148
+ }, [metrics, content.items, content.layoutPlan, fontRevision]);
149
+
150
+ useEffect(() => {
151
+ if (process.env.NEXT_PUBLIC_PAPYRUS_CONTENT_LOAD_TIMING !== "1") return;
152
+ if (!metrics) {
153
+ console.info(`[papyrus-content-load] newspaper-awaiting-metrics ${JSON.stringify({ editionDate: content.editionDate })}`);
154
+ return;
155
+ }
156
+ if (!layout) return;
157
+ console.info(
158
+ `[papyrus-content-load] newspaper-layout-ready ${JSON.stringify({
159
+ editionDate: content.editionDate,
160
+ pageCount: layout.pages.length,
161
+ pageWidth: metrics.pageWidth,
162
+ viewportHeight: metrics.viewportHeight,
163
+ })}`,
164
+ );
165
+ }, [content.editionDate, layout, metrics]);
166
+
167
+ useEffect(() => {
168
+ let active = true;
169
+ setEditorAppendixReady(false);
170
+ if (content.placeholderMode === "emptyEdition" || content.suppressNewsDeskAppendix) {
171
+ setEditorAppendix(null);
172
+ setEditorAppendixReady(true);
173
+ return () => {
174
+ active = false;
175
+ };
176
+ }
177
+ const refreshAppendix = async () => {
178
+ const state = await loadEditorCategoryTreeState({
179
+ scenarioAppendix: content.newsDeskAppendix,
180
+ allowScenarioEditorOverride: content.source === "scenario",
181
+ });
182
+ if (!active) return;
183
+ setEditorAppendix(state.isEditor ? state.appendix : null);
184
+ setEditorAppendixReady(true);
185
+ };
186
+
187
+ void refreshAppendix();
188
+ const unsubscribe = Hub.listen("auth", ({ payload }) => {
189
+ if (
190
+ payload.event === "signedIn" ||
191
+ payload.event === "signedOut" ||
192
+ payload.event === "signInWithRedirect" ||
193
+ payload.event === "signInWithRedirect_failure"
194
+ ) {
195
+ void refreshAppendix();
196
+ }
197
+ });
198
+ return () => {
199
+ active = false;
200
+ unsubscribe();
201
+ };
202
+ }, [content.newsDeskAppendix, content.placeholderMode, content.source, content.suppressNewsDeskAppendix]);
203
+
204
+ const appendixPages = useMemo(() => buildNewsDeskAppendixPages(editorAppendix, layout?.pages.length ?? 0), [editorAppendix, layout?.pages.length]);
205
+ const totalPages = (layout?.pages.length ?? 0) + appendixPages.length;
206
+ const mastheadHref = pathname === "/" ? (mastheadHomeHref ?? "/") : "/";
207
+
208
+ useEffect(() => {
209
+ if (!layout) return;
210
+ setVisiblePage((current) => clampPageNumber(current, totalPages));
211
+ setPriorityPages((current) => {
212
+ const next = new Set<number>();
213
+ next.add(clampPageNumber(visiblePage, totalPages));
214
+ for (const page of current) next.add(clampPageNumber(page, totalPages));
215
+ return next;
216
+ });
217
+ }, [layout, totalPages, visiblePage]);
218
+
219
+ const materializedPages = useMemo(() => {
220
+ if (!layout) return new Set<number>();
221
+ return computeMaterializedPages(visiblePage, priorityPages, totalPages);
222
+ }, [layout, priorityPages, totalPages, visiblePage]);
223
+
224
+ const articleAnchors = useMemo(() => computeArticleAnchors(layout), [layout]);
225
+
226
+ const scrollToTarget = useCallback(
227
+ (target: HTMLElement, options?: ScrollToPageOptions) => {
228
+ const offsetY = progressRef.current?.getBoundingClientRect().height ?? 0;
229
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
230
+ const targetY = window.scrollY + target.getBoundingClientRect().top - offsetY;
231
+ if (options?.immediate || reducedMotion) {
232
+ window.scrollTo(0, targetY);
233
+ return;
234
+ }
235
+
236
+ const distance = Math.abs(targetY - window.scrollY);
237
+ const duration = clamp(distance / 1200, 0.45, 1.1);
238
+ gsap.killTweensOf(window);
239
+ gsap.to(window, {
240
+ duration,
241
+ ease: "power3.inOut",
242
+ scrollTo: {
243
+ y: targetY,
244
+ autoKill: true,
245
+ },
246
+ });
247
+ },
248
+ [],
249
+ );
250
+
251
+ const scrollToPage = useCallback(
252
+ (rawPageNumber: number, options?: ScrollToPageOptions) => {
253
+ if (!layout) return;
254
+ const pageNumber = clampPageNumber(rawPageNumber, totalPages);
255
+ pendingProgrammaticPageRef.current = pageNumber;
256
+ preservedItemHashPageRef.current = null;
257
+ if (!preserveContentLocation) writePageLocation(pageNumber, options?.history ?? "push", editionBasePath);
258
+ setPriorityPages((current) => {
259
+ const next = new Set(current);
260
+ next.add(pageNumber);
261
+ return next;
262
+ });
263
+
264
+ requestAnimationFrame(() => {
265
+ const target = pageRefs.current.get(pageNumber) ?? document.getElementById(getPageAnchorId(pageNumber));
266
+ if (!target) return;
267
+ scrollToTarget(target, options);
268
+ });
269
+ },
270
+ [editionBasePath, layout, preserveContentLocation, scrollToTarget, totalPages],
271
+ );
272
+
273
+ const scrollToItemAnchor = useCallback(
274
+ (articleSlug: string, options?: ScrollToPageOptions) => {
275
+ if (!layout) return false;
276
+ const pageNumber = articleAnchors.pages.get(articleSlug);
277
+ if (!pageNumber) return false;
278
+
279
+ pendingProgrammaticPageRef.current = pageNumber;
280
+ preservedItemHashPageRef.current = pageNumber;
281
+ setPriorityPages((current) => {
282
+ const next = new Set(current);
283
+ next.add(pageNumber);
284
+ return next;
285
+ });
286
+
287
+ const tryScroll = (attempt: number) => {
288
+ requestAnimationFrame(() => {
289
+ const target = document.getElementById(articleSlug);
290
+ if (!target && attempt < 8) {
291
+ tryScroll(attempt + 1);
292
+ return;
293
+ }
294
+
295
+ const fallbackTarget = pageRefs.current.get(pageNumber) ?? document.getElementById(getPageAnchorId(pageNumber));
296
+ const scrollTarget = target ?? fallbackTarget;
297
+ if (scrollTarget) scrollToTarget(scrollTarget, options);
298
+ });
299
+ };
300
+
301
+ tryScroll(0);
302
+ return true;
303
+ },
304
+ [articleAnchors.pages, layout, scrollToTarget],
305
+ );
306
+
307
+ const turnRelative = useCallback(
308
+ (delta: -1 | 1) => {
309
+ if (!layout) return;
310
+ const targetPage = visiblePage + delta;
311
+ if (targetPage < 1 || targetPage > totalPages) return;
312
+ scrollToPage(targetPage);
313
+ },
314
+ [layout, scrollToPage, totalPages, visiblePage],
315
+ );
316
+
317
+ useVisiblePageObserver(layout, totalPages, setVisiblePage, pageRefs);
318
+
319
+ useEffect(() => {
320
+ if (!layout) return;
321
+ if (hasInitializedLocationRef.current) return;
322
+ hasInitializedLocationRef.current = true;
323
+
324
+ const itemHash = parseItemAnchorHash(window.location.hash);
325
+ if (itemHash && scrollToItemAnchor(itemHash, { immediate: true, history: "none" })) return;
326
+
327
+ if (initialSectionKey) {
328
+ const sectionSlug = findInitialSectionArticleSlug(content, initialSectionKey);
329
+ if (sectionSlug && scrollToItemAnchor(sectionSlug, { immediate: true, history: "none" })) return;
330
+ }
331
+
332
+ const locationPage = editionBasePath
333
+ ? parsePageNumberFromPath(window.location.pathname, editionBasePath)
334
+ : parseHashPageNumber(window.location.hash);
335
+ if (!locationPage) {
336
+ scrollToPage(normalizedInitialPage, { immediate: true, history: "replace" });
337
+ return;
338
+ }
339
+ scrollToPage(locationPage, { immediate: true, history: "replace" });
340
+ }, [content, editionBasePath, initialSectionKey, layout, normalizedInitialPage, scrollToItemAnchor, scrollToPage]);
341
+
342
+ useEffect(() => {
343
+ if (!layout) return;
344
+ const pendingPage = pendingProgrammaticPageRef.current;
345
+ if (pendingPage !== null && pendingPage !== visiblePage) return;
346
+ pendingProgrammaticPageRef.current = null;
347
+
348
+ const preservedItemHashPage = preservedItemHashPageRef.current;
349
+ if (preservedItemHashPage !== null && parseItemAnchorHash(window.location.hash)) {
350
+ if (preservedItemHashPage === visiblePage) return;
351
+ preservedItemHashPageRef.current = null;
352
+ }
353
+
354
+ if (!preserveContentLocation) writePageLocation(visiblePage, "replace", editionBasePath);
355
+ }, [editionBasePath, layout, preserveContentLocation, visiblePage]);
356
+
357
+ useEffect(() => {
358
+ if (!layout) return;
359
+ const handleLocationNavigation = () => {
360
+ const itemHash = parseItemAnchorHash(window.location.hash);
361
+ if (itemHash && scrollToItemAnchor(itemHash, { history: "none" })) return;
362
+
363
+ const pageNumber = editionBasePath
364
+ ? parsePageNumberFromPath(window.location.pathname, editionBasePath)
365
+ : parseHashPageNumber(window.location.hash);
366
+ if (!pageNumber) return;
367
+ scrollToPage(pageNumber, { history: "none" });
368
+ };
369
+
370
+ window.addEventListener("hashchange", handleLocationNavigation);
371
+ window.addEventListener("popstate", handleLocationNavigation);
372
+ return () => {
373
+ window.removeEventListener("hashchange", handleLocationNavigation);
374
+ window.removeEventListener("popstate", handleLocationNavigation);
375
+ };
376
+ }, [editionBasePath, layout, scrollToItemAnchor, scrollToPage]);
377
+
378
+ useEffect(() => {
379
+ const handleKeyDown = (event: KeyboardEvent) => {
380
+ if (isEditableEventTarget(event.target)) return;
381
+
382
+ if ((event.key === "=" || event.code === "Equal") && event.ctrlKey && !event.metaKey && !event.altKey) {
383
+ event.preventDefault();
384
+ setShowRhythmOverlay((current) => !current);
385
+ }
386
+ };
387
+
388
+ window.addEventListener("keydown", handleKeyDown);
389
+ return () => window.removeEventListener("keydown", handleKeyDown);
390
+ }, []);
391
+
392
+ useEffect(() => {
393
+ const papyrusWindow = window as PapyrusTestWindow;
394
+ if (!layout) {
395
+ delete papyrusWindow.__PAPYRUS_LAYOUT__;
396
+ delete papyrusWindow.__PAPYRUS_TOTAL_PAGES__;
397
+ delete papyrusWindow.__PAPYRUS_VISIBLE_PAGE__;
398
+ delete papyrusWindow.__PAPYRUS_SCROLL_TO_PAGE__;
399
+ papyrusWindow.__PAPYRUS_SCENARIO__ = content.scenarioId;
400
+ return;
401
+ }
402
+
403
+ papyrusWindow.__PAPYRUS_LAYOUT__ = layout;
404
+ papyrusWindow.__PAPYRUS_TOTAL_PAGES__ = totalPages;
405
+ papyrusWindow.__PAPYRUS_SCENARIO__ = content.scenarioId;
406
+ papyrusWindow.__PAPYRUS_VISIBLE_PAGE__ = visiblePage;
407
+ papyrusWindow.__PAPYRUS_SCROLL_TO_PAGE__ = scrollToPage;
408
+ return () => {
409
+ if (papyrusWindow.__PAPYRUS_LAYOUT__ === layout) delete papyrusWindow.__PAPYRUS_LAYOUT__;
410
+ if (papyrusWindow.__PAPYRUS_TOTAL_PAGES__ === totalPages) delete papyrusWindow.__PAPYRUS_TOTAL_PAGES__;
411
+ if (papyrusWindow.__PAPYRUS_VISIBLE_PAGE__ === visiblePage) delete papyrusWindow.__PAPYRUS_VISIBLE_PAGE__;
412
+ if (papyrusWindow.__PAPYRUS_SCROLL_TO_PAGE__ === scrollToPage) delete papyrusWindow.__PAPYRUS_SCROLL_TO_PAGE__;
413
+ };
414
+ }, [content.scenarioId, layout, scrollToPage, totalPages, visiblePage]);
415
+
416
+ return (
417
+ <main
418
+ className="site-shell"
419
+ data-content-source={content.source}
420
+ data-current-page={layout ? visiblePage : undefined}
421
+ data-layout-plan-front-template={content.layoutPlan.pages[0]?.presetId}
422
+ data-news-desk-appendix-enabled={appendixPages.length > 0 ? "true" : "false"}
423
+ data-news-desk-appendix-ready={editorAppendixReady ? "true" : "false"}
424
+ data-rhythm-overlay={showRhythmOverlay ? "true" : "false"}
425
+ data-scenario-id={content.scenarioId}
426
+ ref={shellRef}
427
+ style={
428
+ layout
429
+ ? ({
430
+ "--paper-rhythm": `${layout.rhythm.rowHeight}px`,
431
+ } as CSSProperties)
432
+ : undefined
433
+ }
434
+ >
435
+ <EditionProgress
436
+ currentPage={layout ? visiblePage : null}
437
+ onNext={layout && visiblePage < totalPages ? () => turnRelative(1) : undefined}
438
+ onPrevious={layout && visiblePage > 1 ? () => turnRelative(-1) : undefined}
439
+ progressRef={progressRef}
440
+ totalPages={layout ? totalPages : null}
441
+ />
442
+ {!layout ? (
443
+ <LoadingPage content={content} mastheadHref={mastheadHref} />
444
+ ) : (
445
+ <section className="scroll-edition">
446
+ {layout.pages.map((page) => {
447
+ const pageHeight = getSolvedPageHeight(layout, page.pageNumber);
448
+ const materialized = materializedPages.has(page.pageNumber);
449
+ return (
450
+ <div
451
+ className={`paper-page ${page.kind === "front" ? "paper-page--front" : ""} ${
452
+ page.pageNumber === visiblePage ? "paper-page--active" : ""
453
+ } ${materialized ? "paper-page--materialized" : "paper-page--placeholder"}`}
454
+ data-materialized={materialized ? "true" : "false"}
455
+ data-page-kind={page.kind}
456
+ id={getPageAnchorId(page.pageNumber)}
457
+ key={page.id}
458
+ ref={(node) => {
459
+ if (node) pageRefs.current.set(page.pageNumber, node);
460
+ else pageRefs.current.delete(page.pageNumber);
461
+ }}
462
+ style={{ minHeight: pageHeight }}
463
+ >
464
+ {materialized ? (
465
+ <SolvedPageView
466
+ articleAnchorBlockIds={articleAnchors.blockIds}
467
+ content={content}
468
+ editionBasePath={editionBasePath}
469
+ layout={layout}
470
+ mastheadHref={mastheadHref}
471
+ page={page}
472
+ resolvedTheme={resolvedTheme}
473
+ scrollToItemAnchor={scrollToItemAnchor}
474
+ scrollToPage={scrollToPage}
475
+ />
476
+ ) : (
477
+ <PagePlaceholder page={page} />
478
+ )}
479
+ </div>
480
+ );
481
+ })}
482
+ {appendixPages.map((page) => {
483
+ const pageHeight = layout.pageHeight;
484
+ const materialized = materializedPages.has(page.pageNumber);
485
+ return (
486
+ <div
487
+ className={`paper-page paper-page--news-desk-appendix ${
488
+ page.pageNumber === visiblePage ? "paper-page--active" : ""
489
+ } ${materialized ? "paper-page--materialized" : "paper-page--placeholder"}`}
490
+ data-materialized={materialized ? "true" : "false"}
491
+ data-page-kind="newsDeskAppendix"
492
+ id={getPageAnchorId(page.pageNumber)}
493
+ key={page.id}
494
+ ref={(node) => {
495
+ if (node) pageRefs.current.set(page.pageNumber, node);
496
+ else pageRefs.current.delete(page.pageNumber);
497
+ }}
498
+ style={{ minHeight: pageHeight }}
499
+ >
500
+ {materialized ? (
501
+ <NewsDeskAppendixPageView
502
+ editionBasePath={editionBasePath}
503
+ layout={layout}
504
+ page={page}
505
+ scrollToPage={scrollToPage}
506
+ />
507
+ ) : (
508
+ <AppendixPagePlaceholder pageNumber={page.pageNumber} />
509
+ )}
510
+ </div>
511
+ );
512
+ })}
513
+ </section>
514
+ )}
515
+ </main>
516
+ );
517
+ }
518
+
519
+ export function NewspaperFrontPreview({ content }: { content: EditionContent }) {
520
+ const resolvedTheme = useResolvedPapyrusTheme();
521
+ const containerRef = useRef<HTMLDivElement | null>(null);
522
+ const [layout, setLayout] = useState<NewspaperLayout | null>(null);
523
+ const [scale, setScale] = useState(0.24);
524
+ const [previewRhythm, setPreviewRhythm] = useState(19);
525
+ const page = layout?.pages.find((candidate) => candidate.pageNumber === 1) ?? layout?.pages[0] ?? null;
526
+ const rawPreviewHeight = (page?.height ?? ARCHIVE_PREVIEW_HEIGHT) * scale;
527
+ const previewHeight = Math.ceil(rawPreviewHeight / previewRhythm) * previewRhythm;
528
+ const articleAnchors = useMemo(() => computeArticleAnchors(layout), [layout]);
529
+ const scrollToPage = useCallback(() => undefined, []);
530
+ const scrollToItemAnchor = useCallback(() => false, []);
531
+
532
+ useEffect(() => {
533
+ setLayout(buildNewspaperLayout(content.items, ARCHIVE_PREVIEW_WIDTH, ARCHIVE_PREVIEW_HEIGHT, content.layoutPlan));
534
+ }, [content]);
535
+
536
+ useEffect(() => {
537
+ const node = containerRef.current;
538
+ if (!node) return;
539
+ const updateScale = () => {
540
+ const width = node.getBoundingClientRect().width;
541
+ if (width > 0) setScale(width / ARCHIVE_PREVIEW_WIDTH);
542
+ setPreviewRhythm(window.matchMedia("(max-width: 719px)").matches ? 18 : 19);
543
+ };
544
+ const observer = new ResizeObserver(updateScale);
545
+ observer.observe(node);
546
+ updateScale();
547
+ return () => observer.disconnect();
548
+ }, []);
549
+
550
+ return (
551
+ <div
552
+ className={`archive-front-preview ${page ? "" : "archive-front-preview--loading"}`}
553
+ data-archive-front-preview="true"
554
+ ref={containerRef}
555
+ style={{ height: previewHeight }}
556
+ >
557
+ {layout && page ? (
558
+ <div
559
+ className="archive-front-preview__page"
560
+ style={{
561
+ width: ARCHIVE_PREVIEW_WIDTH,
562
+ height: page.height,
563
+ transform: `scale(${scale})`,
564
+ }}
565
+ >
566
+ <SolvedPageView
567
+ articleAnchorBlockIds={articleAnchors.blockIds}
568
+ content={content}
569
+ disableLinks
570
+ editionTitleId={`archive-edition-title-${content.id}`}
571
+ layout={layout}
572
+ mastheadHref="/"
573
+ page={page}
574
+ resolvedTheme={resolvedTheme}
575
+ scrollToItemAnchor={scrollToItemAnchor}
576
+ scrollToPage={scrollToPage}
577
+ />
578
+ </div>
579
+ ) : null}
580
+ </div>
581
+ );
582
+ }
583
+
584
+ function EditionProgressTriangleIcon({ direction }: { direction: "previous" | "next" }) {
585
+ const path = direction === "previous" ? "M7.5 1 2.5 5 7.5 9Z" : "M2.5 1 7.5 5 2.5 9Z";
586
+
587
+ return (
588
+ <svg aria-hidden="true" className="edition-progress__icon" focusable="false" viewBox="0 0 10 10">
589
+ <path d={path} fill="currentColor" />
590
+ </svg>
591
+ );
592
+ }
593
+
594
+ function EditionProgress({
595
+ currentPage,
596
+ totalPages,
597
+ onNext,
598
+ onPrevious,
599
+ progressRef,
600
+ }: {
601
+ currentPage: number | null;
602
+ totalPages: number | null;
603
+ onNext?: () => void;
604
+ onPrevious?: () => void;
605
+ progressRef: React.MutableRefObject<HTMLElement | null>;
606
+ }) {
607
+ return (
608
+ <nav className="edition-progress" aria-label="Edition progress" ref={progressRef}>
609
+ <button
610
+ className="edition-progress__button edition-progress__button--previous"
611
+ disabled={!onPrevious}
612
+ onClick={onPrevious}
613
+ type="button"
614
+ >
615
+ <EditionProgressTriangleIcon direction="previous" />
616
+ Previous
617
+ </button>
618
+ <span className="edition-progress__status">
619
+ Page {currentPage ?? "--"} of {totalPages ?? "--"}
620
+ </span>
621
+ <button
622
+ className="edition-progress__button edition-progress__button--next"
623
+ disabled={!onNext}
624
+ onClick={onNext}
625
+ type="button"
626
+ >
627
+ Next
628
+ <EditionProgressTriangleIcon direction="next" />
629
+ </button>
630
+ </nav>
631
+ );
632
+ }
633
+
634
+ function PagePlaceholder({ page }: { page: SolvedPage }) {
635
+ return (
636
+ <section className="paper-page-content paper-page-content--placeholder" data-page-kind={page.kind} data-page-preset-id={page.presetId}>
637
+ <div className="page-placeholder-label">Loading Page {page.pageNumber}</div>
638
+ </section>
639
+ );
640
+ }
641
+
642
+ function AppendixPagePlaceholder({ pageNumber }: { pageNumber: number }) {
643
+ return (
644
+ <section className="paper-page-content paper-page-content--placeholder" data-page-kind="newsDeskAppendix">
645
+ <div className="page-placeholder-label">Loading Newsroom Page {pageNumber}</div>
646
+ </section>
647
+ );
648
+ }
649
+
650
+ function LoadingPage({ content, mastheadHref }: { content: EditionContent; mastheadHref: string }) {
651
+ const frontEditionLabel = getFrontEditionLabel(content.title);
652
+ return (
653
+ <section className="paper-page-content paper-page-content--front paper-page-content--loading" aria-label="Loading edition">
654
+ <header className="masthead">
655
+ <div className="masthead__rule" />
656
+ <h1>
657
+ <Link href={mastheadHref}>{SITE_BRAND.mastheadTitle}</Link>
658
+ </h1>
659
+ <div className="masthead__meta">
660
+ <span>{formatShortDate(content.editionDate)}</span>
661
+ <span>INFORMATION ABOUT INFORMATION SYSTEMS</span>
662
+ <span>{frontEditionLabel}</span>
663
+ </div>
664
+ </header>
665
+ </section>
666
+ );
667
+ }
668
+
669
+ function SolvedPageView({
670
+ articleAnchorBlockIds,
671
+ content,
672
+ disableLinks = false,
673
+ editionBasePath,
674
+ editionTitleId: rawEditionTitleId,
675
+ layout,
676
+ mastheadHref,
677
+ page,
678
+ resolvedTheme,
679
+ scrollToItemAnchor,
680
+ scrollToPage,
681
+ }: {
682
+ articleAnchorBlockIds: Set<string>;
683
+ content: EditionContent;
684
+ disableLinks?: boolean;
685
+ editionBasePath?: string;
686
+ editionTitleId?: string;
687
+ layout: NewspaperLayout;
688
+ mastheadHref: string;
689
+ page: SolvedPage;
690
+ resolvedTheme: ResolvedTheme;
691
+ scrollToItemAnchor: (articleSlug: string, options?: ScrollToPageOptions) => boolean;
692
+ scrollToPage: (pageNumber: number, options?: ScrollToPageOptions) => void;
693
+ }) {
694
+ const front = page.kind === "front";
695
+ const frontEditionLabel = getFrontEditionLabel(content.title);
696
+ const editionTitleId = front ? rawEditionTitleId ?? "edition-title" : undefined;
697
+ const frontRegion = front ? page.regions[0] : undefined;
698
+ return (
699
+ <section
700
+ className={`paper-page-content ${front ? "paper-page-content--front" : "paper-page-content--inside"}`}
701
+ data-page-kind={page.kind}
702
+ data-page-preset-id={page.presetId}
703
+ aria-labelledby={editionTitleId}
704
+ style={getPageStyle(layout, page.pageNumber)}
705
+ >
706
+ {front ? (
707
+ <header className="masthead">
708
+ <div className="masthead__rule" />
709
+ <h1 id={editionTitleId}>
710
+ <Link href={mastheadHref}>{SITE_BRAND.mastheadTitle}</Link>
711
+ </h1>
712
+ <div className="masthead__meta">
713
+ <span>{formatShortDate(content.editionDate)}</span>
714
+ <span>INFORMATION ABOUT INFORMATION SYSTEMS</span>
715
+ <span>{frontEditionLabel}</span>
716
+ </div>
717
+ </header>
718
+ ) : (
719
+ <header className="inside-header">
720
+ <span>Page {page.pageNumber}</span>
721
+ <span>{formatPageSections(page)}</span>
722
+ <span>{formatPageKind(page)}</span>
723
+ </header>
724
+ )}
725
+
726
+ {front ? (
727
+ <>
728
+ <div
729
+ className="front-grid"
730
+ style={
731
+ {
732
+ "--paper-columns": page.columnCount,
733
+ "--paper-gap": `${layout.gap}px`,
734
+ height: frontRegion ? `${frontRegion.height}px` : undefined,
735
+ gridTemplateRows: frontRegion?.rowHeights?.map((height) => `${height}px`).join(" "),
736
+ } as CSSProperties
737
+ }
738
+ >
739
+ {page.regions.flatMap((region) => region.blocks).map((block, index) => (
740
+ <FrontStoryBlock
741
+ anchorId={articleAnchorBlockIds.has(block.id) ? block.article?.slug : undefined}
742
+ block={block}
743
+ disableLinks={disableLinks}
744
+ editionBasePath={editionBasePath}
745
+ index={index}
746
+ key={block.id}
747
+ resolvedTheme={resolvedTheme}
748
+ scrollToPage={scrollToPage}
749
+ suppressDirectArticleLink={content.placeholderMode === "emptyEdition"}
750
+ />
751
+ ))}
752
+ </div>
753
+ {page.frontFooter ? (
754
+ <FrontPageFooter
755
+ disableLinks={disableLinks}
756
+ editionBasePath={editionBasePath}
757
+ footer={page.frontFooter}
758
+ scrollToItemAnchor={scrollToItemAnchor}
759
+ />
760
+ ) : null}
761
+ </>
762
+ ) : (
763
+ <div className={`solved-regions solved-regions--${page.kind}`}>
764
+ {page.regions.map((region) => (
765
+ <SolvedRegionView
766
+ articleAnchorBlockIds={articleAnchorBlockIds}
767
+ editionBasePath={editionBasePath}
768
+ key={region.id}
769
+ layout={layout}
770
+ region={region}
771
+ resolvedTheme={resolvedTheme}
772
+ />
773
+ ))}
774
+ </div>
775
+ )}
776
+ </section>
777
+ );
778
+ }
779
+
780
+ function NewsDeskAppendixPageView({
781
+ editionBasePath,
782
+ layout,
783
+ page,
784
+ scrollToPage,
785
+ }: {
786
+ editionBasePath?: string;
787
+ layout: NewspaperLayout;
788
+ page: NewsDeskAppendixPage;
789
+ scrollToPage: (pageNumber: number, options?: ScrollToPageOptions) => void;
790
+ }) {
791
+ const title = page.mode === "register" ? "Canonical Category Register" : page.root?.shortTitle ?? page.root?.displayName ?? "Category Page";
792
+ return (
793
+ <section
794
+ className="paper-page-content paper-page-content--inside paper-page-content--news-desk-appendix"
795
+ data-news-desk-appendix-page={page.mode}
796
+ data-page-kind="newsDeskAppendix"
797
+ style={getPageStyle(layout, page.pageNumber)}
798
+ >
799
+ <header className="inside-header">
800
+ <span>Page {page.pageNumber}</span>
801
+ <span>Newsroom</span>
802
+ <span>{page.mode === "register" ? "Category Register" : "Category Page"}</span>
803
+ </header>
804
+ <article className="news-desk-appendix" aria-labelledby={`news-desk-appendix-title-${page.pageNumber}`}>
805
+ <p className="story-label">Newsroom Appendix</p>
806
+ <h2 id={`news-desk-appendix-title-${page.pageNumber}`}>{title}</h2>
807
+ {page.mode === "register" ? (
808
+ <TopicRegisterPage editionBasePath={editionBasePath} page={page} scrollToPage={scrollToPage} />
809
+ ) : (
810
+ <RootTopicPage page={page} />
811
+ )}
812
+ </article>
813
+ </section>
814
+ );
815
+ }
816
+
817
+ function TopicRegisterPage({
818
+ editionBasePath,
819
+ page,
820
+ scrollToPage,
821
+ }: {
822
+ editionBasePath?: string;
823
+ page: NewsDeskAppendixPage;
824
+ scrollToPage: (pageNumber: number, options?: ScrollToPageOptions) => void;
825
+ }) {
826
+ return (
827
+ <>
828
+ <p className="news-desk-appendix__lede">
829
+ {page.appendix.description ?? "The accepted canonical category tree currently steering corpus analysis and newsroom coverage."}
830
+ </p>
831
+ <div className="news-desk-appendix__ledger" data-news-desk-category-register>
832
+ {page.roots.map((root, index) => {
833
+ const rootPageNumber = page.pageNumber + index + 1;
834
+ const subcategoryCount = page.appendix.nodes.filter((node) => node.parentCategoryKey === root.categoryKey && node.status === "accepted").length;
835
+ const href = getPageHref(rootPageNumber, editionBasePath);
836
+ return (
837
+ <article className="news-desk-appendix__category-summary" key={root.id}>
838
+ <header>
839
+ <h3>
840
+ <a href={href} onClick={(event) => handleAppendixPageClick(event, rootPageNumber, scrollToPage)}>
841
+ {root.shortTitle ?? root.displayName}
842
+ </a>
843
+ </h3>
844
+ <span>{subcategoryCount} subcategories</span>
845
+ </header>
846
+ {root.shortTitle ? <p className="news-desk-appendix__subtitle">{root.displayName}</p> : null}
847
+ {root.subtitle ? <p className="news-desk-appendix__subtitle">{root.subtitle}</p> : null}
848
+ <p>{root.description ?? "Accepted root category."}</p>
849
+ <CategoryEvidenceLine node={root} />
850
+ </article>
851
+ );
852
+ })}
853
+ </div>
854
+ <footer className="news-desk-appendix__footer">
855
+ <span>{page.appendix.displayName}</span>
856
+ {page.appendix.generatedAt ? <span>Accepted state {formatShortDate(page.appendix.generatedAt)}</span> : null}
857
+ </footer>
858
+ </>
859
+ );
860
+ }
861
+
862
+ function RootTopicPage({ page }: { page: NewsDeskAppendixPage }) {
863
+ const root = page.root;
864
+ if (!root) return null;
865
+ return (
866
+ <>
867
+ {root.subtitle ? <p className="news-desk-appendix__subtitle news-desk-appendix__subtitle--root">{root.subtitle}</p> : null}
868
+ <p className="news-desk-appendix__lede">{root.description ?? "Accepted root category."}</p>
869
+ <div className="news-desk-appendix__evidence-band" aria-label="Category evidence counts">
870
+ <CategoryEvidenceMetric label="Seed references" value={compactTextArray(root.seedItemIds).length} />
871
+ <CategoryEvidenceMetric label="Holdout references" value={compactTextArray(root.holdoutItemIds).length} />
872
+ <CategoryEvidenceMetric label="Accepted subcategories" value={page.subcategories.length} />
873
+ </div>
874
+ <section className="news-desk-appendix__subcategories" aria-label={`${root.displayName} accepted subcategories`}>
875
+ <header>
876
+ <h3>Accepted Subcategories</h3>
877
+ <Link href="/newsroom">Review related notes in Newsroom</Link>
878
+ </header>
879
+ {page.subcategories.length ? page.subcategories.map((subcategory) => (
880
+ <article className="news-desk-appendix__subcategory" data-news-desk-subcategory={subcategory.categoryKey} key={subcategory.id}>
881
+ <h4>{subcategory.shortTitle ?? subcategory.displayName}</h4>
882
+ {subcategory.shortTitle ? <p className="news-desk-appendix__subtitle">{subcategory.displayName}</p> : null}
883
+ {subcategory.subtitle ? <p className="news-desk-appendix__subtitle">{subcategory.subtitle}</p> : null}
884
+ <p>{subcategory.description ?? "Accepted subcategory."}</p>
885
+ <CategoryEvidenceLine node={subcategory} />
886
+ </article>
887
+ )) : (
888
+ <p className="news-desk-appendix__empty">No accepted subcategories have been filed for this category yet.</p>
889
+ )}
890
+ </section>
891
+ </>
892
+ );
893
+ }
894
+
895
+ function CategoryEvidenceMetric({ label, value }: { label: string; value: number }) {
896
+ return (
897
+ <div>
898
+ <strong>{value}</strong>
899
+ <span>{label}</span>
900
+ </div>
901
+ );
902
+ }
903
+
904
+ function CategoryEvidenceLine({ node }: { node: NewsDeskCategoryTreeNode }) {
905
+ const seedCount = compactTextArray(node.seedItemIds).length;
906
+ const holdoutCount = compactTextArray(node.holdoutItemIds).length;
907
+ return (
908
+ <small className="news-desk-appendix__evidence-line">
909
+ {seedCount} seed refs / {holdoutCount} holdout refs / {node.categoryKey}
910
+ </small>
911
+ );
912
+ }
913
+
914
+ function handleAppendixPageClick(
915
+ event: ReactMouseEvent<HTMLAnchorElement>,
916
+ pageNumber: number,
917
+ scrollToPage: (pageNumber: number, options?: ScrollToPageOptions) => void,
918
+ ) {
919
+ event.preventDefault();
920
+ scrollToPage(pageNumber);
921
+ }
922
+
923
+ function FrontPageFooter({
924
+ disableLinks = false,
925
+ editionBasePath,
926
+ footer,
927
+ scrollToItemAnchor,
928
+ }: {
929
+ disableLinks?: boolean;
930
+ editionBasePath?: string;
931
+ footer: SolvedFrontFooter;
932
+ scrollToItemAnchor: (articleSlug: string, options?: ScrollToPageOptions) => boolean;
933
+ }) {
934
+ return (
935
+ <footer
936
+ aria-label="Front page footer"
937
+ className="front-footer"
938
+ data-front-footer="true"
939
+ style={
940
+ {
941
+ "--front-footer-height": `${footer.height}px`,
942
+ "--front-footer-margin-top": `${footer.marginTop}px`,
943
+ "--front-footer-row-height": `${footer.rowHeight}px`,
944
+ "--front-footer-section-columns": footer.sectionColumns,
945
+ "--front-footer-section-rows": footer.sectionRows,
946
+ } as CSSProperties
947
+ }
948
+ >
949
+ <div className="front-footer__heading">
950
+ <span>{SITE_BRAND.mastheadSubtitle}</span>
951
+ <span>{footer.entries.length} sections</span>
952
+ </div>
953
+ {footer.entries.length > 0 ? (
954
+ <nav className="front-footer__sections" aria-label="Front page sections">
955
+ {footer.entries.map((entry) => {
956
+ const href = getItemAnchorHref(entry.articleSlug, entry.pageNumber, editionBasePath);
957
+ if (disableLinks) {
958
+ return (
959
+ <span className="front-footer__section-link" data-footer-section={entry.section} key={`${entry.section}-${entry.articleSlug}`}>
960
+ <span className="front-footer__section-name">{entry.section}</span>
961
+ <span className="front-footer__section-title">{entry.articleTitle}</span>
962
+ </span>
963
+ );
964
+ }
965
+ return (
966
+ <a
967
+ className="front-footer__section-link"
968
+ data-footer-section={entry.section}
969
+ href={href}
970
+ key={`${entry.section}-${entry.articleSlug}`}
971
+ onClick={(event) => handleFooterSectionClick(event, entry.articleSlug, href, scrollToItemAnchor)}
972
+ >
973
+ <span className="front-footer__section-name">{entry.section}</span>
974
+ <span className="front-footer__section-title">{entry.articleTitle}</span>
975
+ </a>
976
+ );
977
+ })}
978
+ </nav>
979
+ ) : null}
980
+ <div className="front-footer__utilities" aria-label="Publication utilities">
981
+ {footer.utilityEntries.map((entry) => {
982
+ if (entry.id === "newsDesk" && disableLinks) return null;
983
+
984
+ if (entry.id === "login" && !disableLinks) {
985
+ return (
986
+ <ReaderAuthControl className="front-footer__utility-link" dataFooterUtility={entry.id} key={entry.id} postAuthPath={editionBasePath} />
987
+ );
988
+ }
989
+
990
+ return entry.disabled || disableLinks ? (
991
+ <span
992
+ aria-disabled="true"
993
+ className="front-footer__utility-link"
994
+ data-footer-utility={entry.id}
995
+ key={entry.id}
996
+ role="link"
997
+ >
998
+ {entry.label}
999
+ </span>
1000
+ ) : (
1001
+ <Link className="front-footer__utility-link" data-footer-utility={entry.id} href={entry.href} key={entry.id}>
1002
+ {entry.label}
1003
+ </Link>
1004
+ );
1005
+ })}
1006
+ </div>
1007
+ </footer>
1008
+ );
1009
+ }
1010
+
1011
+ function handleFooterSectionClick(
1012
+ event: ReactMouseEvent<HTMLAnchorElement>,
1013
+ articleSlug: string,
1014
+ href: string,
1015
+ scrollToItemAnchor: (articleSlug: string, options?: ScrollToPageOptions) => boolean,
1016
+ ) {
1017
+ event.preventDefault();
1018
+ window.history.pushState(null, "", href);
1019
+ scrollToItemAnchor(articleSlug, { history: "none" });
1020
+ }
1021
+
1022
+ function SolvedRegionView({
1023
+ articleAnchorBlockIds,
1024
+ editionBasePath,
1025
+ layout,
1026
+ region,
1027
+ resolvedTheme,
1028
+ }: {
1029
+ articleAnchorBlockIds: Set<string>;
1030
+ editionBasePath?: string;
1031
+ layout: NewspaperLayout;
1032
+ region: SolvedRegion;
1033
+ resolvedTheme: ResolvedTheme;
1034
+ }) {
1035
+ return (
1036
+ <section
1037
+ className={`solved-region solved-region--${region.type} continuation-section`}
1038
+ data-region-id={region.id}
1039
+ data-region-role={region.role}
1040
+ style={{ height: region.height }}
1041
+ >
1042
+ {region.blocks.map((block) => (
1043
+ <SolvedBlockView
1044
+ anchorId={articleAnchorBlockIds.has(block.id) ? block.article?.slug : undefined}
1045
+ block={block}
1046
+ editionBasePath={editionBasePath}
1047
+ key={block.id}
1048
+ layout={layout}
1049
+ resolvedTheme={resolvedTheme}
1050
+ />
1051
+ ))}
1052
+ </section>
1053
+ );
1054
+ }
1055
+
1056
+ function FrontStoryBlock({
1057
+ anchorId,
1058
+ block,
1059
+ disableLinks = false,
1060
+ editionBasePath,
1061
+ resolvedTheme,
1062
+ scrollToPage,
1063
+ suppressDirectArticleLink = false,
1064
+ }: {
1065
+ anchorId?: string;
1066
+ block: SolvedBlock;
1067
+ disableLinks?: boolean;
1068
+ editionBasePath?: string;
1069
+ index: number;
1070
+ resolvedTheme: ResolvedTheme;
1071
+ scrollToPage: (pageNumber: number, options?: ScrollToPageOptions) => void;
1072
+ suppressDirectArticleLink?: boolean;
1073
+ }) {
1074
+ const article = block.article;
1075
+ if (!article || !block.front) return null;
1076
+ const articleHref = getArticleHref(article.slug, editionBasePath);
1077
+ const disableArticleLinks = disableLinks || suppressDirectArticleLink;
1078
+ const composed = Boolean(block.front.composition);
1079
+ const preludeImages = block.furniture.filter((furniture): furniture is SolvedImageFurniture => (
1080
+ furniture.kind === "image" && furniture.templateId === "front-prelude"
1081
+ ));
1082
+ const measureFurniture = block.furniture.filter((furniture): furniture is SolvedImageFurniture => (
1083
+ furniture.kind === "image" && furniture.templateId !== "front-prelude"
1084
+ ));
1085
+ if (composed) {
1086
+ return (
1087
+ <article
1088
+ className={`front-story front-story--composed ${block.span >= 4 ? "front-story--lead" : ""}`}
1089
+ data-article-id={article.slug}
1090
+ data-block-id={block.id}
1091
+ data-block-type={block.type}
1092
+ data-block-preset-id={block.presetId}
1093
+ id={anchorId}
1094
+ style={getFrontStoryStyle(block)}
1095
+ >
1096
+ {block.chromeBoxes?.map((box) => (
1097
+ <FrontChromeBoxView
1098
+ articleHref={articleHref}
1099
+ borderTopHeight={block.front?.chrome.borderTopHeight ?? 0}
1100
+ box={box}
1101
+ disableLinks={disableArticleLinks}
1102
+ key={box.id}
1103
+ />
1104
+ ))}
1105
+ {measureFurniture.map((furniture) => (
1106
+ <LeadPhotoFigure
1107
+ furniture={{ ...furniture, y: furniture.y - (block.front?.chrome.borderTopHeight ?? 0) }}
1108
+ key={furniture.id}
1109
+ resolvedTheme={resolvedTheme}
1110
+ />
1111
+ ))}
1112
+ <div
1113
+ className="story-measure story-measure--composed"
1114
+ style={{
1115
+ top: Math.max(0, (block.front.composition?.bodyTop ?? block.front.chromeHeight) - block.front.chrome.borderTopHeight),
1116
+ height: block.front.composition?.bodyHeight ?? block.front.bodySlotHeight,
1117
+ }}
1118
+ >
1119
+ <div
1120
+ className="story-composition-grid"
1121
+ style={
1122
+ {
1123
+ "--paper-columns": block.columnCount,
1124
+ } as CSSProperties
1125
+ }
1126
+ >
1127
+ {block.columns.map((column, columnIndex) => (
1128
+ <div className="story-composition-column" key={columnIndex}>
1129
+ <MeasuredLines lines={column} />
1130
+ </div>
1131
+ ))}
1132
+ </div>
1133
+ </div>
1134
+ {block.jumpTargetPage || !suppressDirectArticleLink ? (
1135
+ <div className="jump-line">
1136
+ {block.jumpTargetPage ? (
1137
+ disableLinks ? (
1138
+ <span>{block.jumpLabel ?? `SEE MORE ON A${block.jumpTargetPage}`}</span>
1139
+ ) : (
1140
+ <Link href={getPageHref(block.jumpTargetPage, editionBasePath)} onClick={(event) => handleJumpClick(event, block.jumpTargetPage ?? 1, scrollToPage)}>
1141
+ {block.jumpLabel ?? `SEE MORE ON A${block.jumpTargetPage}`}
1142
+ </Link>
1143
+ )
1144
+ ) : (
1145
+ disableLinks ? <span>Read the full article</span> : <Link href={articleHref}>Read the full article</Link>
1146
+ )}
1147
+ </div>
1148
+ ) : null}
1149
+ </article>
1150
+ );
1151
+ }
1152
+ return (
1153
+ <article
1154
+ className={`front-story ${block.span >= 4 ? "front-story--lead" : ""} ${preludeImages.length > 0 ? "front-story--feature" : ""}`}
1155
+ data-article-id={article.slug}
1156
+ data-block-id={block.id}
1157
+ data-block-type={block.type}
1158
+ data-block-preset-id={block.presetId}
1159
+ id={anchorId}
1160
+ style={getFrontStoryStyle(block)}
1161
+ >
1162
+ {preludeImages.map((furniture) => <LeadPhotoFigure furniture={furniture} key={furniture.id} resolvedTheme={resolvedTheme} />)}
1163
+ <div className="story-label">{article.section}</div>
1164
+ <h2>
1165
+ {disableArticleLinks ? <span>{article.headline}</span> : <Link href={articleHref}>{article.headline}</Link>}
1166
+ </h2>
1167
+ <p className="story-deck">{article.deck}</p>
1168
+ <div className="story-byline">{`${article.byline} / ${article.dateline}`}</div>
1169
+ <div className="story-measure" style={{ height: block.front.bodySlotHeight + block.front.chrome.measureChromeHeight }}>
1170
+ {measureFurniture.map((furniture) => <LeadPhotoFigure furniture={furniture} key={furniture.id} resolvedTheme={resolvedTheme} />)}
1171
+ <MeasuredLines lines={block.columns[0] ?? []} />
1172
+ </div>
1173
+ {block.jumpTargetPage || !suppressDirectArticleLink ? (
1174
+ <div className="jump-line">
1175
+ {block.jumpTargetPage ? (
1176
+ disableLinks ? (
1177
+ <span>{block.jumpLabel ?? `SEE MORE ON A${block.jumpTargetPage}`}</span>
1178
+ ) : (
1179
+ <Link href={getPageHref(block.jumpTargetPage, editionBasePath)} onClick={(event) => handleJumpClick(event, block.jumpTargetPage ?? 1, scrollToPage)}>
1180
+ {block.jumpLabel ?? `SEE MORE ON A${block.jumpTargetPage}`}
1181
+ </Link>
1182
+ )
1183
+ ) : (
1184
+ disableLinks ? <span>Read the full article</span> : <Link href={articleHref}>Read the full article</Link>
1185
+ )}
1186
+ </div>
1187
+ ) : null}
1188
+ </article>
1189
+ );
1190
+ }
1191
+
1192
+ function handleJumpClick(event: ReactMouseEvent<HTMLAnchorElement>, pageNumber: number, scrollToPage: (pageNumber: number) => void) {
1193
+ event.preventDefault();
1194
+ scrollToPage(pageNumber);
1195
+ }
1196
+
1197
+ function FrontChromeBoxView({
1198
+ articleHref,
1199
+ borderTopHeight,
1200
+ box,
1201
+ disableLinks = false,
1202
+ }: {
1203
+ articleHref: string;
1204
+ borderTopHeight: number;
1205
+ box: SolvedChromeBox;
1206
+ disableLinks?: boolean;
1207
+ }) {
1208
+ const style = getChromeBoxStyle(box, borderTopHeight);
1209
+ if (box.slot === "headline") {
1210
+ return (
1211
+ <h2 className="front-chrome-box front-chrome-box--headline" data-chrome-slot={box.slot} style={style}>
1212
+ {disableLinks ? <span>{box.text}</span> : <Link href={articleHref}>{box.text}</Link>}
1213
+ </h2>
1214
+ );
1215
+ }
1216
+ if (box.slot === "deck") {
1217
+ return (
1218
+ <p className="front-chrome-box front-chrome-box--deck" data-chrome-slot={box.slot} style={style}>
1219
+ {box.text}
1220
+ </p>
1221
+ );
1222
+ }
1223
+ return (
1224
+ <div className={`front-chrome-box front-chrome-box--${box.slot}`} data-chrome-slot={box.slot} style={style}>
1225
+ {box.text}
1226
+ </div>
1227
+ );
1228
+ }
1229
+
1230
+ function SolvedBlockView({
1231
+ anchorId,
1232
+ block,
1233
+ editionBasePath,
1234
+ layout,
1235
+ resolvedTheme,
1236
+ }: {
1237
+ anchorId?: string;
1238
+ block: SolvedBlock;
1239
+ editionBasePath?: string;
1240
+ layout: NewspaperLayout;
1241
+ resolvedTheme: ResolvedTheme;
1242
+ }) {
1243
+ if (block.type === "articleFrame" && block.article) {
1244
+ return <ArticleFrameBlock anchorId={anchorId} block={block} editionBasePath={editionBasePath} layout={layout} resolvedTheme={resolvedTheme} />;
1245
+ }
1246
+ if (block.type === "itemStack" && block.items?.length) {
1247
+ return <ItemStackBlock block={block} />;
1248
+ }
1249
+ if (block.type === "adBlock") {
1250
+ return <AdBlock block={block} resolvedTheme={resolvedTheme} />;
1251
+ }
1252
+ return (
1253
+ <article className={`solved-block solved-block--${block.type}`} data-block-id={block.id} data-block-type={block.type} style={{ height: block.height }}>
1254
+ {block.title ? <h2>{block.title}</h2> : null}
1255
+ </article>
1256
+ );
1257
+ }
1258
+
1259
+ function ItemStackBlock({ block }: { block: SolvedBlock }) {
1260
+ const items = block.items ?? [];
1261
+ const panelGroups = getPlaceholderItemGroups(items);
1262
+ const headerDescription = getPlaceholderStackDescription(block);
1263
+ return (
1264
+ <article
1265
+ className={`solved-block solved-block--itemStack placeholder-item-stack placeholder-item-stack--${toClassSuffix(block.title ?? "stack")}`}
1266
+ data-block-id={block.id}
1267
+ data-block-type={block.type}
1268
+ style={
1269
+ {
1270
+ height: block.height,
1271
+ "--placeholder-stack-columns": block.columnCount,
1272
+ } as CSSProperties
1273
+ }
1274
+ >
1275
+ {block.title ? (
1276
+ <header className="placeholder-item-stack__header">
1277
+ <div className="placeholder-item-stack__title-lockup">
1278
+ <p className="placeholder-item-stack__eyebrow">Public placeholder register</p>
1279
+ <h2>{block.title}</h2>
1280
+ </div>
1281
+ {headerDescription ? <p className="placeholder-item-stack__deck">{headerDescription}</p> : null}
1282
+ </header>
1283
+ ) : null}
1284
+ <div className="placeholder-item-stack__body">
1285
+ {panelGroups.map((group) => (
1286
+ <section className="placeholder-item-stack__group" data-placeholder-group={group.key} key={group.key}>
1287
+ {group.label ? <h3>{group.label}</h3> : null}
1288
+ <div className="placeholder-item-stack__grid">
1289
+ {group.items.map((item) => (
1290
+ <PlaceholderItemPanel item={item} key={item.slug} />
1291
+ ))}
1292
+ </div>
1293
+ </section>
1294
+ ))}
1295
+ </div>
1296
+ </article>
1297
+ );
1298
+ }
1299
+
1300
+ function getPlaceholderStackDescription(block: SolvedBlock): string | null {
1301
+ if (block.id === "empty-edition-sections-stack") {
1302
+ return "These sections are configured by the human editorial staff in the Newsroom.";
1303
+ }
1304
+ if (block.id === "empty-edition-topics-stack") {
1305
+ return "These topics are data-driven: derived from the knowledge base of curated references using AI/ML techniques.";
1306
+ }
1307
+ return null;
1308
+ }
1309
+
1310
+ function PlaceholderItemPanel({ item }: { item: PublicationItem }) {
1311
+ const kind = getMetadataString(item, "placeholderKind") ?? item.type;
1312
+ const body = item.type === "article" ? item.body : item.body ?? [];
1313
+ const title = item.type === "article" ? item.headline : item.title;
1314
+ const deck = item.deck;
1315
+ const href = item.type === "article" ? undefined : item.href;
1316
+ const ctaLabel = getMetadataString(item, "ctaLabel") ?? "Open";
1317
+ const isCta = kind === "cta";
1318
+ return (
1319
+ <article
1320
+ className={`placeholder-panel placeholder-panel--${toClassSuffix(item.type)} placeholder-panel--${toClassSuffix(kind)}`}
1321
+ data-item-id={item.slug}
1322
+ data-item-type={item.type}
1323
+ data-placeholder-kind={kind}
1324
+ >
1325
+ <p className="placeholder-panel__kicker">{getPlaceholderPanelKicker(item)}</p>
1326
+ <h4>{href ? <Link href={href}>{ctaLabel}</Link> : title}</h4>
1327
+ {deck ? <p className="placeholder-panel__deck">{deck}</p> : null}
1328
+ {body.map((paragraph, index) => (
1329
+ <p className="placeholder-panel__body" key={`${item.slug}-body-${index}`}>{paragraph}</p>
1330
+ ))}
1331
+ {href && !isCta ? <Link className="placeholder-panel__link" href={href}>{ctaLabel}</Link> : null}
1332
+ </article>
1333
+ );
1334
+ }
1335
+
1336
+ function getPlaceholderItemGroups(items: PublicationItem[]): Array<{ key: string; label?: string; items: PublicationItem[] }> {
1337
+ const sectionItems = items.filter((item) => getMetadataString(item, "placeholderKind") === "section");
1338
+ const ctaItems = items.filter((item) => getMetadataString(item, "placeholderKind") === "cta");
1339
+ if (sectionItems.length + ctaItems.length === items.length && sectionItems.length > 0) {
1340
+ return [
1341
+ {
1342
+ key: "canonical",
1343
+ label: "Canonical Sections",
1344
+ items: sectionItems.filter((item) => getMetadataString(item, "placeholderGroup") === "canonical"),
1345
+ },
1346
+ {
1347
+ key: "rotating",
1348
+ label: "Rotating Sections",
1349
+ items: [
1350
+ ...sectionItems.filter((item) => getMetadataString(item, "placeholderGroup") === "rotating"),
1351
+ ...ctaItems,
1352
+ ],
1353
+ },
1354
+ ].filter((group) => group.items.length > 0);
1355
+ }
1356
+
1357
+ return [{ key: "items", items }];
1358
+ }
1359
+
1360
+ function getPlaceholderPanelKicker(item: PublicationItem): string {
1361
+ const kind = getMetadataString(item, "placeholderKind");
1362
+ const placeholderType = getMetadataString(item, "placeholderType");
1363
+ if (kind === "section" && placeholderType === "canonical") return "Canonical Section";
1364
+ if (kind === "section") return "Rotating Section";
1365
+ if (kind === "topic") return "Curated Root Topic";
1366
+ if (kind === "cta") return "Newsroom";
1367
+ return item.section ?? item.type;
1368
+ }
1369
+
1370
+ function getMetadataString(item: PublicationItem, key: string): string | null {
1371
+ if (item.type === "article") return null;
1372
+ const value = item.metadata?.[key];
1373
+ return typeof value === "string" && value.trim() ? value.trim() : null;
1374
+ }
1375
+
1376
+ function toClassSuffix(value: string): string {
1377
+ const suffix = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1378
+ return suffix || "item";
1379
+ }
1380
+
1381
+ function ArticleFrameBlock({
1382
+ anchorId,
1383
+ block,
1384
+ editionBasePath,
1385
+ layout,
1386
+ resolvedTheme,
1387
+ }: {
1388
+ anchorId?: string;
1389
+ block: SolvedBlock;
1390
+ editionBasePath?: string;
1391
+ layout: NewspaperLayout;
1392
+ resolvedTheme: ResolvedTheme;
1393
+ }) {
1394
+ const article = block.article;
1395
+ if (!article) return null;
1396
+ return (
1397
+ <article
1398
+ className={`solved-block solved-block--articleFrame continuation-section ${block.furniture.some((item) => item.kind === "image") ? "continuation-section--has-photo" : ""}`}
1399
+ data-article-id={article.slug}
1400
+ data-block-id={block.id}
1401
+ data-block-type={block.type}
1402
+ data-block-preset-id={block.presetId}
1403
+ id={anchorId}
1404
+ style={getArticleBlockStyle(layout, block)}
1405
+ >
1406
+ <div className="continued-title">
1407
+ <p>{block.label ?? article.section}</p>
1408
+ <h2>
1409
+ <Link href={getArticleHref(article.slug, editionBasePath)}>{article.headline}</Link>
1410
+ </h2>
1411
+ </div>
1412
+ <div className="continuation-body">
1413
+ <div
1414
+ className="continuation-grid"
1415
+ style={
1416
+ {
1417
+ "--paper-columns": block.columnCount,
1418
+ "--paper-gap": `${layout.gap}px`,
1419
+ } as CSSProperties
1420
+ }
1421
+ >
1422
+ {block.columns.map((column, columnIndex) => (
1423
+ <div className="continuation-column" key={columnIndex}>
1424
+ <MeasuredLines lines={column} />
1425
+ </div>
1426
+ ))}
1427
+ </div>
1428
+ {block.furniture.map((furniture) => (
1429
+ <SolvedFurnitureView furniture={furniture} key={furniture.id} resolvedTheme={resolvedTheme} />
1430
+ ))}
1431
+ </div>
1432
+ </article>
1433
+ );
1434
+ }
1435
+
1436
+ function SolvedFurnitureView({ furniture, resolvedTheme }: { furniture: SolvedFurniture; resolvedTheme: ResolvedTheme }) {
1437
+ if (furniture.kind === "image") return <ContinuationPhotoFigure furniture={furniture} resolvedTheme={resolvedTheme} />;
1438
+ if (furniture.kind === "pullQuote") return <ContinuationPullQuoteAside furniture={furniture} />;
1439
+ if (furniture.kind === "ad") {
1440
+ return (
1441
+ <figure className="solved-ad" style={{ left: furniture.x, top: furniture.y, width: furniture.width, height: furniture.height }}>
1442
+ {furniture.src ? <Image src={furniture.src} alt={furniture.label} fill sizes="100vw" unoptimized={shouldBypassImageOptimization(furniture.src)} /> : null}
1443
+ <figcaption>{furniture.label}</figcaption>
1444
+ </figure>
1445
+ );
1446
+ }
1447
+ return null;
1448
+ }
1449
+
1450
+ function LeadPhotoFigure({ furniture, resolvedTheme }: { furniture: SolvedImageFurniture; resolvedTheme: ResolvedTheme }) {
1451
+ const prelude = furniture.templateId === "front-prelude";
1452
+ const imageSrc = resolveThemedImageSrc(furniture.src, furniture.themeVariants, resolvedTheme);
1453
+ return (
1454
+ <figure
1455
+ className={prelude ? "front-prelude-photo" : "lead-photo"}
1456
+ data-furniture-kind="image"
1457
+ style={getImageFigureStyle(furniture, prelude)}
1458
+ >
1459
+ <div className="photo-frame">
1460
+ <Image
1461
+ src={imageSrc}
1462
+ alt={furniture.alt}
1463
+ fill
1464
+ sizes="(max-width: 719px) 100vw, 40vw"
1465
+ priority
1466
+ style={{ objectFit: furniture.objectFit, objectPosition: furniture.objectPosition }}
1467
+ unoptimized={shouldBypassImageOptimization(imageSrc)}
1468
+ />
1469
+ </div>
1470
+ {furniture.caption ? <figcaption>{furniture.caption}</figcaption> : null}
1471
+ </figure>
1472
+ );
1473
+ }
1474
+
1475
+ function ContinuationPhotoFigure({ furniture, resolvedTheme }: { furniture: SolvedImageFurniture; resolvedTheme: ResolvedTheme }) {
1476
+ const imageSrc = resolveThemedImageSrc(furniture.src, furniture.themeVariants, resolvedTheme);
1477
+ return (
1478
+ <figure
1479
+ className={`continuation-photo continuation-photo--${furniture.templateId}`}
1480
+ data-furniture-kind="image"
1481
+ style={getImageFigureStyle(furniture)}
1482
+ >
1483
+ <div className="photo-frame">
1484
+ <Image
1485
+ src={imageSrc}
1486
+ alt={furniture.alt}
1487
+ fill
1488
+ sizes={furniture.columnSpan > 1 ? "(max-width: 719px) 100vw, 48vw" : "(max-width: 1039px) 50vw, 24vw"}
1489
+ style={{ objectFit: furniture.objectFit, objectPosition: furniture.objectPosition }}
1490
+ unoptimized={shouldBypassImageOptimization(imageSrc)}
1491
+ />
1492
+ </div>
1493
+ {furniture.caption ? <figcaption>{furniture.caption}</figcaption> : null}
1494
+ </figure>
1495
+ );
1496
+ }
1497
+
1498
+ function getImageFigureStyle(furniture: SolvedImageFurniture, prelude = false): CSSProperties {
1499
+ return {
1500
+ ...(prelude ? {} : { left: furniture.x, top: furniture.y, width: furniture.width }),
1501
+ height: furniture.height,
1502
+ "--image-frame-height": `${furniture.imageHeight}px`,
1503
+ "--image-caption-height": `${furniture.captionHeight}px`,
1504
+ "--image-caption-font-size": `${furniture.captionFontSize}px`,
1505
+ "--image-caption-line-height": `${furniture.captionLineHeight}px`,
1506
+ } as CSSProperties;
1507
+ }
1508
+
1509
+ function ContinuationPullQuoteAside({ furniture }: { furniture: SolvedPullQuoteFurniture }) {
1510
+ return (
1511
+ <aside
1512
+ className={`continuation-pullquote continuation-pullquote--${furniture.templateId}`}
1513
+ data-furniture-kind="pullQuote"
1514
+ style={{
1515
+ left: furniture.x,
1516
+ top: furniture.y,
1517
+ width: furniture.width,
1518
+ height: furniture.height,
1519
+ fontSize: furniture.fontSize,
1520
+ lineHeight: `${furniture.lineHeight}px`,
1521
+ }}
1522
+ >
1523
+ <span className="continuation-pullquote__text">{furniture.text}</span>
1524
+ </aside>
1525
+ );
1526
+ }
1527
+
1528
+ function AdBlock({ block, resolvedTheme }: { block: SolvedBlock; resolvedTheme: ResolvedTheme }) {
1529
+ return (
1530
+ <article className="solved-block solved-block--adBlock" data-block-id={block.id} data-block-type={block.type} style={{ height: block.height }}>
1531
+ {block.furniture.map((furniture) => <SolvedFurnitureView furniture={furniture} key={furniture.id} resolvedTheme={resolvedTheme} />)}
1532
+ </article>
1533
+ );
1534
+ }
1535
+
1536
+ function MeasuredLines({ lines }: { lines: TextLine[] }) {
1537
+ const height = lines.length === 0 ? 0 : Math.max(...lines.map((line) => line.y + line.paintHeight));
1538
+ return (
1539
+ <div className="measured-lines" style={{ height }}>
1540
+ {lines.map((line, index) => (
1541
+ <span
1542
+ className="measured-line"
1543
+ key={`${index}-${line.text}`}
1544
+ style={{
1545
+ left: line.x,
1546
+ top: line.y,
1547
+ height: line.paintHeight,
1548
+ width: Math.ceil(line.width) + 4,
1549
+ fontFamily: line.fontFamily,
1550
+ fontSize: line.fontSize,
1551
+ lineHeight: `${line.lineHeight}px`,
1552
+ }}
1553
+ >
1554
+ {line.text}
1555
+ </span>
1556
+ ))}
1557
+ </div>
1558
+ );
1559
+ }
1560
+
1561
+ function getPageStyle(layout: NewspaperLayout, pageNumber: number): CSSProperties {
1562
+ return {
1563
+ "--page-height": `${getSolvedPageHeight(layout, pageNumber)}px`,
1564
+ "--paper-rhythm": `${layout.rhythm.rowHeight}px`,
1565
+ "--paper-line-paint-height": `${layout.rhythm.paintHeight}px`,
1566
+ "--paper-row-gap": `${layout.rowGap}px`,
1567
+ "--page-padding-top": `${layout.pageChrome.pagePaddingTop}px`,
1568
+ "--page-padding-x": `${layout.pageChrome.pagePaddingX}px`,
1569
+ "--page-padding-bottom": `${layout.pageChrome.pagePaddingBottom}px`,
1570
+ "--masthead-height": `${layout.pageChrome.mastheadHeight}px`,
1571
+ "--masthead-kicker-line-height": `${layout.pageChrome.mastheadKickerLineHeight}px`,
1572
+ "--masthead-title-font-size": `${layout.pageChrome.mastheadTitleFontSize}px`,
1573
+ "--masthead-title-line-height": `${layout.pageChrome.mastheadTitleLineHeight}px`,
1574
+ "--masthead-title-margin-top": `${layout.pageChrome.mastheadTitleMarginTop}px`,
1575
+ "--masthead-title-margin-bottom": `${layout.pageChrome.mastheadTitleMarginBottom}px`,
1576
+ "--masthead-title-optical-shift": `${layout.pageChrome.mastheadTitleOpticalShift}px`,
1577
+ "--masthead-meta-line-height": `${layout.pageChrome.mastheadMetaLineHeight}px`,
1578
+ "--masthead-meta-gap": `${layout.pageChrome.mastheadMetaGap}px`,
1579
+ "--masthead-meta-padding-top": `${layout.pageChrome.mastheadMetaPaddingTop}px`,
1580
+ "--front-grid-margin-top": `${layout.pageChrome.frontGridMarginTop}px`,
1581
+ "--inside-header-height": `${layout.pageChrome.insideHeaderHeight}px`,
1582
+ "--continuation-section-separator-height": `${layout.pageChrome.continuationSectionSeparatorHeight}px`,
1583
+ "--continued-title-font-size": `${layout.pageChrome.continuedTitleFontSize}px`,
1584
+ "--continued-title-line-height": `${layout.pageChrome.continuedTitleLineHeight}px`,
1585
+ } as CSSProperties;
1586
+ }
1587
+
1588
+ function getSolvedPageHeight(layout: NewspaperLayout, pageNumber: number): number {
1589
+ return layout.pageHeights[pageNumber] ?? layout.pageHeight;
1590
+ }
1591
+
1592
+ function getFrontStoryStyle(block: SolvedBlock): CSSProperties {
1593
+ const front = block.front;
1594
+ if (!front) return {};
1595
+ const chrome = front.chrome;
1596
+ const gridPlacement = front.gridPlacement;
1597
+ return {
1598
+ gridColumn: gridPlacement ? `${gridPlacement.columnStart + 1} / span ${gridPlacement.columnSpan}` : `span ${block.span}`,
1599
+ gridRow: gridPlacement ? `${gridPlacement.rowStart + 1} / span ${gridPlacement.rowSpan}` : undefined,
1600
+ "--story-row-height": `${front.rowHeight}px`,
1601
+ "--story-border-top": `${chrome.borderTopHeight}px`,
1602
+ "--story-padding-top": `${chrome.paddingTop}px`,
1603
+ "--story-measure-chrome-height": `${chrome.measureChromeHeight}px`,
1604
+ "--story-media-prelude-height": `${chrome.mediaPreludeHeight}px`,
1605
+ "--story-media-prelude-margin-bottom": `${chrome.mediaPreludeMarginBottom}px`,
1606
+ "--story-label-font-size": `${chrome.labelFontSize}px`,
1607
+ "--story-label-line-height": `${chrome.labelLineHeight}px`,
1608
+ "--story-label-height": `${chrome.labelHeight}px`,
1609
+ "--story-label-paint-buffer": `${chrome.labelPaintBuffer}px`,
1610
+ "--story-headline-font-size": `${chrome.headlineFontSize}px`,
1611
+ "--story-headline-line-height": `${chrome.headlineLineHeight}px`,
1612
+ "--story-headline-height": `${chrome.headlineHeight}px`,
1613
+ "--story-headline-paint-buffer": `${chrome.headlinePaintBuffer}px`,
1614
+ "--story-headline-margin-top": `${chrome.headlineMarginTop}px`,
1615
+ "--story-headline-margin-bottom": `${chrome.headlineMarginBottom}px`,
1616
+ "--story-deck-font-size": `${chrome.deckFontSize}px`,
1617
+ "--story-deck-line-height": `${chrome.deckLineHeight}px`,
1618
+ "--story-deck-height": `${chrome.deckHeight}px`,
1619
+ "--story-deck-paint-buffer": `${chrome.deckPaintBuffer}px`,
1620
+ "--story-deck-padding-top": `${chrome.deckPaddingTop}px`,
1621
+ "--story-deck-padding-bottom": `${chrome.deckPaddingBottom}px`,
1622
+ "--story-deck-rule-top-height": `${chrome.deckRuleTopHeight}px`,
1623
+ "--story-deck-rule-bottom-height": `${chrome.deckRuleBottomHeight}px`,
1624
+ "--story-deck-margin-bottom": `${chrome.deckMarginBottom}px`,
1625
+ "--story-byline-font-size": `${chrome.bylineFontSize}px`,
1626
+ "--story-byline-line-height": `${chrome.bylineLineHeight}px`,
1627
+ "--story-byline-height": `${chrome.bylineHeight}px`,
1628
+ "--story-byline-paint-buffer": `${chrome.bylinePaintBuffer}px`,
1629
+ "--story-byline-padding-top": `${chrome.bylinePaddingTop}px`,
1630
+ "--story-byline-padding-bottom": `${chrome.bylinePaddingBottom}px`,
1631
+ "--story-byline-margin-bottom": `${chrome.bylineMarginBottom}px`,
1632
+ "--story-jump-height": `${front.jumpReserveHeight}px`,
1633
+ "--story-jump-font-size": `${chrome.jumpFontSize}px`,
1634
+ "--story-jump-line-height": `${chrome.jumpLineHeight}px`,
1635
+ "--story-jump-paint-buffer": `${chrome.jumpPaintBuffer}px`,
1636
+ "--story-jump-padding-top": `${chrome.jumpPaddingTop}px`,
1637
+ "--story-jump-padding-bottom": `${chrome.jumpPaddingBottom}px`,
1638
+ "--story-jump-border-top": `${chrome.jumpBorderTopHeight}px`,
1639
+ } as CSSProperties;
1640
+ }
1641
+
1642
+ function getChromeBoxStyle(box: SolvedChromeBox, borderTopHeight: number): CSSProperties {
1643
+ return {
1644
+ left: box.x,
1645
+ top: Math.max(0, box.y - borderTopHeight),
1646
+ width: box.width,
1647
+ height: box.height,
1648
+ fontSize: box.fontSize,
1649
+ lineHeight: `${box.lineHeight}px`,
1650
+ fontWeight: box.fontWeight,
1651
+ fontStyle: box.fontStyle,
1652
+ textTransform: box.textTransform,
1653
+ "--chrome-box-paint-buffer": `${box.paintBuffer}px`,
1654
+ "--chrome-box-padding-top": `${box.paddingTop ?? 0}px`,
1655
+ "--chrome-box-padding-bottom": `${box.paddingBottom ?? 0}px`,
1656
+ "--chrome-box-rule-top-height": `${box.ruleTopHeight ?? 0}px`,
1657
+ "--chrome-box-rule-bottom-height": `${box.ruleBottomHeight ?? 0}px`,
1658
+ } as CSSProperties;
1659
+ }
1660
+
1661
+ function getArticleBlockStyle(layout: NewspaperLayout, block: SolvedBlock): CSSProperties {
1662
+ const image = block.furniture.find((item): item is SolvedImageFurniture => item.kind === "image");
1663
+ const pullQuote = block.furniture.find((item): item is SolvedPullQuoteFurniture => item.kind === "pullQuote");
1664
+ const title = block.titleChrome;
1665
+ return {
1666
+ width: block.width,
1667
+ height: block.height,
1668
+ minHeight: block.height,
1669
+ "--continued-title-height": `${title?.totalHeight ?? 0}px`,
1670
+ "--continued-kicker-font-size": `${title?.label.fontSize ?? 11.5}px`,
1671
+ "--continued-kicker-line-height": `${title?.label.lineHeight ?? 14}px`,
1672
+ "--continued-kicker-height": `${title?.label.height ?? 14}px`,
1673
+ "--continued-kicker-paint-buffer": `${title?.label.paintBuffer ?? 0}px`,
1674
+ "--continued-title-font-size": `${title?.heading.fontSize ?? layout.pageChrome.continuedTitleFontSize}px`,
1675
+ "--continued-title-line-height": `${title?.heading.lineHeight ?? layout.pageChrome.continuedTitleLineHeight}px`,
1676
+ "--continued-title-heading-height": `${title?.heading.height ?? block.titleHeight ?? 0}px`,
1677
+ "--continued-title-heading-paint-buffer": `${title?.heading.paintBuffer ?? 0}px`,
1678
+ "--continued-title-heading-margin-top": `${title?.heading.marginBefore ?? 5}px`,
1679
+ "--continuation-column-height": `${block.bodyHeight ?? 0}px`,
1680
+ "--continuation-pullquote-height": `${pullQuote?.height ?? 0}px`,
1681
+ "--continuation-pullquote-x": `${pullQuote?.x ?? 0}px`,
1682
+ "--continuation-pullquote-y": `${pullQuote?.y ?? 0}px`,
1683
+ "--continuation-pullquote-width": `${pullQuote?.width ?? 0}px`,
1684
+ "--continuation-pullquote-font-size": `${pullQuote?.fontSize ?? 22}px`,
1685
+ "--continuation-pullquote-line-height": `${pullQuote?.lineHeight ?? 25}px`,
1686
+ "--continuation-photo-height": `${image?.height ?? 0}px`,
1687
+ "--continuation-photo-x": `${image?.x ?? 0}px`,
1688
+ "--continuation-photo-y": `${image?.y ?? 0}px`,
1689
+ "--continuation-photo-width": `${image?.width ?? layout.contentWidth}px`,
1690
+ "--paper-gap": `${layout.gap}px`,
1691
+ } as CSSProperties;
1692
+ }
1693
+
1694
+ function formatPageSections(page: SolvedPage): string {
1695
+ const sections = page.regions.flatMap((region) => region.blocks.map((block) => block.section).filter(Boolean));
1696
+ return Array.from(new Set(sections)).join(" / ");
1697
+ }
1698
+
1699
+ function formatPageKind(page: SolvedPage): string {
1700
+ if (page.kind === "regionStack") return "Stacked page";
1701
+ if (page.kind === "railMain") return "Rail page";
1702
+ return "Article page";
1703
+ }
1704
+
1705
+ function buildNewsDeskAppendixPages(appendix: NewsDeskAppendix | null | undefined, basePageCount: number): NewsDeskAppendixPage[] {
1706
+ if (!appendix?.nodes?.length || basePageCount <= 0) return [];
1707
+ const acceptedNodes = appendix.nodes.filter((node) => node.status === "accepted");
1708
+ const roots = sortAppendixNodes(acceptedNodes.filter((node) => !node.parentCategoryKey));
1709
+ if (!roots.length) return [];
1710
+
1711
+ const pages: NewsDeskAppendixPage[] = [
1712
+ {
1713
+ id: `${appendix.categorySetId}-register`,
1714
+ pageNumber: basePageCount + 1,
1715
+ mode: "register",
1716
+ appendix,
1717
+ roots,
1718
+ subcategories: [],
1719
+ },
1720
+ ];
1721
+ roots.forEach((root, index) => {
1722
+ pages.push({
1723
+ id: `${appendix.categorySetId}-${root.categoryKey}`,
1724
+ pageNumber: basePageCount + index + 2,
1725
+ mode: "category",
1726
+ appendix,
1727
+ roots,
1728
+ root,
1729
+ subcategories: sortAppendixNodes(acceptedNodes.filter((node) => node.parentCategoryKey === root.categoryKey)),
1730
+ });
1731
+ });
1732
+ return pages;
1733
+ }
1734
+
1735
+ function sortAppendixNodes(nodes: NewsDeskCategoryTreeNode[]): NewsDeskCategoryTreeNode[] {
1736
+ return [...nodes].sort((left, right) => {
1737
+ const rankDiff = (left.rank ?? 999999) - (right.rank ?? 999999);
1738
+ if (rankDiff !== 0) return rankDiff;
1739
+ return left.displayName.localeCompare(right.displayName);
1740
+ });
1741
+ }
1742
+
1743
+ function computeMaterializedPages(visiblePage: number, priorityPages: Set<number>, totalPages: number): Set<number> {
1744
+ const pages = new Set<number>();
1745
+ for (const pageNumber of priorityPages) addMaterializedWindow(pages, pageNumber, totalPages);
1746
+ addMaterializedWindow(pages, visiblePage, totalPages);
1747
+ return pages;
1748
+ }
1749
+
1750
+ function computeArticleAnchors(layout: NewspaperLayout | null): { pages: Map<string, number>; blockIds: Set<string> } {
1751
+ const pages = new Map<string, number>();
1752
+ const blockIds = new Set<string>();
1753
+ if (!layout) return { pages, blockIds };
1754
+
1755
+ for (const page of layout.pages) {
1756
+ for (const region of page.regions) {
1757
+ for (const block of region.blocks) {
1758
+ const slug = block.article?.slug;
1759
+ if (!slug || pages.has(slug)) continue;
1760
+ pages.set(slug, page.pageNumber);
1761
+ blockIds.add(block.id);
1762
+ }
1763
+ }
1764
+ }
1765
+
1766
+ return { pages, blockIds };
1767
+ }
1768
+
1769
+ function findInitialSectionArticleSlug(content: EditionContent, sectionKey: string): string | null {
1770
+ const section = content.sections.find((candidate) => candidate.key === sectionKey);
1771
+ return section?.itemIds[0] ?? null;
1772
+ }
1773
+
1774
+ function addMaterializedWindow(pages: Set<number>, center: number, totalPages: number) {
1775
+ for (let delta = -PAGE_LOOKAROUND; delta <= PAGE_LOOKAROUND; delta += 1) {
1776
+ const candidate = clampPageNumber(center + delta, totalPages);
1777
+ pages.add(candidate);
1778
+ }
1779
+ }
1780
+
1781
+ function useVisiblePageObserver(
1782
+ layout: NewspaperLayout | null,
1783
+ totalPages: number,
1784
+ setVisiblePage: React.Dispatch<React.SetStateAction<number>>,
1785
+ pageRefs: React.MutableRefObject<Map<number, HTMLElement>>,
1786
+ ) {
1787
+ useEffect(() => {
1788
+ if (!layout) return;
1789
+ const ratios = new Map<number, number>();
1790
+ const observer = new IntersectionObserver(
1791
+ (entries) => {
1792
+ for (const entry of entries) {
1793
+ const pageNumber = Number((entry.target as HTMLElement).dataset.pageNumber ?? 0);
1794
+ if (!pageNumber) continue;
1795
+ ratios.set(pageNumber, entry.intersectionRatio);
1796
+ }
1797
+ let bestPage = 1;
1798
+ let bestRatio = -1;
1799
+ for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
1800
+ const ratio = ratios.get(pageNumber) ?? 0;
1801
+ if (ratio > bestRatio) {
1802
+ bestRatio = ratio;
1803
+ bestPage = pageNumber;
1804
+ }
1805
+ }
1806
+ setVisiblePage(bestPage);
1807
+ },
1808
+ {
1809
+ root: null,
1810
+ threshold: [0, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 1],
1811
+ },
1812
+ );
1813
+
1814
+ for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
1815
+ const node = pageRefs.current.get(pageNumber);
1816
+ if (!node) continue;
1817
+ node.dataset.pageNumber = String(pageNumber);
1818
+ observer.observe(node);
1819
+ }
1820
+ return () => observer.disconnect();
1821
+ }, [layout, pageRefs, setVisiblePage, totalPages]);
1822
+ }
1823
+
1824
+ function getPageAnchorId(pageNumber: number): string {
1825
+ return `page-${pageNumber}`;
1826
+ }
1827
+
1828
+ function clampPageNumber(pageNumber: number, totalPages: number): number {
1829
+ if (totalPages <= 0) return 1;
1830
+ return Math.max(1, Math.min(pageNumber, totalPages));
1831
+ }
1832
+
1833
+ function parseHashPageNumber(hash: string): number | null {
1834
+ const match = hash.match(/^#page-(\d+)$/i);
1835
+ if (!match) return null;
1836
+ return Number(match[1]);
1837
+ }
1838
+
1839
+ function parseItemAnchorHash(hash: string): string | null {
1840
+ if (!hash || parseHashPageNumber(hash)) return null;
1841
+ try {
1842
+ const value = decodeURIComponent(hash.slice(1)).trim();
1843
+ return /^[a-z0-9][a-z0-9-]*$/i.test(value) ? value : null;
1844
+ } catch {
1845
+ return null;
1846
+ }
1847
+ }
1848
+
1849
+ function parsePageNumberFromPath(pathname: string, editionBasePath: string): number | null {
1850
+ const basePath = normalizePath(editionBasePath);
1851
+ const currentPath = normalizePath(pathname);
1852
+ if (currentPath === basePath) return 1;
1853
+
1854
+ const match = currentPath.match(new RegExp(`^${escapeRegExp(basePath)}/page/(\\d+)$`));
1855
+ if (!match) return null;
1856
+ return Number(match[1]);
1857
+ }
1858
+
1859
+ function writePageLocation(pageNumber: number, mode: NonNullable<ScrollToPageOptions["history"]>, editionBasePath?: string) {
1860
+ if (mode === "none") return;
1861
+
1862
+ const url = editionBasePath ? getPageHref(pageNumber, editionBasePath) : `${window.location.pathname}${window.location.search}#${getPageAnchorId(pageNumber)}`;
1863
+ if (`${window.location.pathname}${window.location.search}${window.location.hash}` === url) return;
1864
+
1865
+ if (mode === "push") {
1866
+ window.history.pushState(null, "", url);
1867
+ return;
1868
+ }
1869
+
1870
+ window.history.replaceState(null, "", url);
1871
+ }
1872
+
1873
+ function getPageHref(pageNumber: number, editionBasePath?: string): string {
1874
+ if (!editionBasePath) return `#${getPageAnchorId(pageNumber)}`;
1875
+ const basePath = normalizePath(editionBasePath);
1876
+ return pageNumber <= 1 ? basePath : `${basePath}/page/${pageNumber}`;
1877
+ }
1878
+
1879
+ function getArticleHref(articleSlug: string, editionBasePath?: string): string {
1880
+ return editionBasePath ? `${normalizePath(editionBasePath)}/${encodeURIComponent(articleSlug)}` : `/articles/${articleSlug}`;
1881
+ }
1882
+
1883
+ function getItemAnchorHref(articleSlug: string, pageNumber: number, editionBasePath?: string): string {
1884
+ const anchor = `#${encodeURIComponent(articleSlug)}`;
1885
+ if (!editionBasePath) return anchor;
1886
+ return `${getPageHref(pageNumber, editionBasePath)}${anchor}`;
1887
+ }
1888
+
1889
+ function compactTextArray(values: Array<string | null> | null | undefined): string[] {
1890
+ return (values ?? []).filter((value): value is string => typeof value === "string" && value.trim().length > 0);
1891
+ }
1892
+
1893
+ function formatShortDate(value: string): string {
1894
+ const timestamp = Date.parse(value);
1895
+ if (Number.isNaN(timestamp)) return value;
1896
+ return new Intl.DateTimeFormat("en-US", {
1897
+ month: "long",
1898
+ day: "numeric",
1899
+ year: "numeric",
1900
+ timeZone: "UTC",
1901
+ }).format(new Date(timestamp));
1902
+ }
1903
+
1904
+ function getFrontEditionLabel(title: string): string {
1905
+ const trimmedTitle = title.trim();
1906
+ return trimmedTitle.length > 0 ? trimmedTitle : "WEEKLY EDITION";
1907
+ }
1908
+
1909
+ function normalizePath(pathname: string): string {
1910
+ const path = pathname.endsWith("/") && pathname !== "/" ? pathname.slice(0, -1) : pathname;
1911
+ return path || "/";
1912
+ }
1913
+
1914
+ function escapeRegExp(value: string): string {
1915
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1916
+ }
1917
+
1918
+ function isEditableEventTarget(target: EventTarget | null) {
1919
+ if (!(target instanceof HTMLElement)) return false;
1920
+
1921
+ const tagName = target.tagName.toLowerCase();
1922
+ if (tagName === "input" || tagName === "textarea" || tagName === "select") return true;
1923
+
1924
+ return target.isContentEditable || target.closest("[contenteditable='true']") !== null;
1925
+ }
1926
+
1927
+ function clamp(value: number, min: number, max: number): number {
1928
+ return Math.max(min, Math.min(max, value));
1929
+ }