@anthusai/papyrus 0.0.0 → 1.0.0-next.10

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 (282) 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 +2069 -0
  5. package/amplify/data/resource.ts +2185 -0
  6. package/amplify/data/schema.js +1882 -0
  7. package/amplify/data/schema.ts +1990 -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/content-actions/handler.py +149 -0
  22. package/amplify/functions/content-actions/requirements.txt +4 -0
  23. package/amplify/functions/content-actions/resource.js +40 -0
  24. package/amplify/functions/content-actions/resource.ts +54 -0
  25. package/amplify/functions/email-submission-processor/handler.py +46 -0
  26. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  27. package/amplify/functions/email-submission-processor/resource.js +56 -0
  28. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  29. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  30. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  31. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  32. package/amplify/functions/knowledge-query/handler.py +1 -0
  33. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  34. package/amplify/functions/knowledge-query/resource.js +50 -0
  35. package/amplify/functions/knowledge-query/resource.ts +61 -0
  36. package/amplify/functions/manage-user-role/handler.ts +799 -0
  37. package/amplify/functions/manage-user-role/resource.js +8 -0
  38. package/amplify/functions/manage-user-role/resource.ts +9 -0
  39. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  40. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  41. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  42. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  43. package/amplify/functions/newsroom-summary/resource.js +8 -0
  44. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  45. package/amplify/functions/procedure-action/handler.ts +621 -0
  46. package/amplify/functions/procedure-action/resource.js +8 -0
  47. package/amplify/functions/procedure-action/resource.ts +9 -0
  48. package/amplify/functions/reader-settings/handler.ts +322 -0
  49. package/amplify/functions/reader-settings/resource.js +8 -0
  50. package/amplify/functions/reader-settings/resource.ts +9 -0
  51. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  52. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  53. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  54. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  55. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  56. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  57. package/amplify/functions/shared/email-submission.ts +415 -0
  58. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  59. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  60. package/amplify/functions/shared/model-payloads.ts +186 -0
  61. package/amplify/functions/shared/python-bundle.js +130 -0
  62. package/amplify/functions/shared/python-bundle.ts +162 -0
  63. package/amplify/functions/shared/slack-events.ts +111 -0
  64. package/amplify/functions/slack-delivery/handler.py +112 -0
  65. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  66. package/amplify/functions/slack-delivery/resource.js +38 -0
  67. package/amplify/functions/slack-delivery/resource.ts +51 -0
  68. package/amplify/functions/slack-events/handler.ts +155 -0
  69. package/amplify/functions/slack-events/resource.js +11 -0
  70. package/amplify/functions/slack-events/resource.ts +12 -0
  71. package/amplify/inbound-email/stack.js +46 -0
  72. package/amplify/inbound-email/stack.ts +61 -0
  73. package/amplify/package.json +3 -0
  74. package/amplify/rebuild-trigger-settings.js +22 -0
  75. package/amplify/rebuild-trigger-settings.ts +42 -0
  76. package/amplify/site-backend-config.js +1 -0
  77. package/amplify/site-backend-config.ts +36 -0
  78. package/amplify/site-backend.js +615 -0
  79. package/amplify/site-backend.ts +739 -0
  80. package/amplify/storage/resource.js +21 -0
  81. package/amplify/storage/resource.ts +22 -0
  82. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  83. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  84. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  85. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  86. package/app/[year]/[month]/[day]/page.tsx +22 -0
  87. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  88. package/app/api/archive/editions/route.ts +18 -0
  89. package/app/api/media/[...path]/route.ts +35 -0
  90. package/app/api/revalidate/route.ts +94 -0
  91. package/app/archive/page.tsx +24 -0
  92. package/app/articles/[slug]/page.tsx +35 -0
  93. package/app/globals.css +10345 -0
  94. package/app/layout.tsx +142 -0
  95. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  96. package/app/newsroom/[section]/page.tsx +47 -0
  97. package/app/newsroom/articles/[id]/page.tsx +15 -0
  98. package/app/newsroom/articles/page.tsx +13 -0
  99. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  100. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  101. package/app/newsroom/insights/page.tsx +14 -0
  102. package/app/newsroom/layout.tsx +12 -0
  103. package/app/newsroom/page.tsx +31 -0
  104. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  105. package/app/page.tsx +272 -0
  106. package/app/papyrus-theme.css +11 -0
  107. package/app/robots.ts +11 -0
  108. package/app/settings/page.tsx +14 -0
  109. package/app/tailwind.css +65 -0
  110. package/bin/papyrus-app.mjs +20 -0
  111. package/bin/papyrus-infra.mjs +95 -0
  112. package/components/ai-elements/conversation.tsx +168 -0
  113. package/components/ai-elements/message.tsx +360 -0
  114. package/components/ai-elements/model-selector.tsx +30 -0
  115. package/components/ai-elements/prompt-input.tsx +1461 -0
  116. package/components/ai-elements/shimmer.tsx +76 -0
  117. package/components/ai-elements/suggestion.tsx +54 -0
  118. package/components/ai-elements/tool.tsx +173 -0
  119. package/components/amplify-client-provider.tsx +140 -0
  120. package/components/archive-grid.tsx +89 -0
  121. package/components/archive-shell.tsx +41 -0
  122. package/components/article-video.tsx +156 -0
  123. package/components/blog-page-background.tsx +20 -0
  124. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  125. package/components/markus-ir-preview.tsx +300 -0
  126. package/components/news-desk-client-provider.tsx +211 -0
  127. package/components/news-desk-page.tsx +116 -0
  128. package/components/news-desk-taxonomy-client.ts +3684 -0
  129. package/components/newspaper.tsx +1929 -0
  130. package/components/newsroom-access-gate.tsx +66 -0
  131. package/components/newsroom-article-editor.tsx +741 -0
  132. package/components/newsroom-articles-view.tsx +129 -0
  133. package/components/newsroom-assignments-view.tsx +664 -0
  134. package/components/newsroom-ops-overview.tsx +226 -0
  135. package/components/newsroom-ops-shell.tsx +321 -0
  136. package/components/newsroom-references-view.tsx +598 -0
  137. package/components/newsroom-topics-view.tsx +425 -0
  138. package/components/papyrus-console-shell.tsx +1912 -0
  139. package/components/pictogram-figure.tsx +65 -0
  140. package/components/presentation-footer.tsx +146 -0
  141. package/components/reader-auth-control.tsx +162 -0
  142. package/components/reader-auth-state.ts +122 -0
  143. package/components/reader-settings.ts +332 -0
  144. package/components/reader-theme-control.tsx +85 -0
  145. package/components/reference-source-preview.tsx +163 -0
  146. package/components/settings-page.tsx +239 -0
  147. package/components/staging-banner.tsx +11 -0
  148. package/components/topic-steering-workspace.tsx +18005 -0
  149. package/components/ui/badge.tsx +30 -0
  150. package/components/ui/button-group.tsx +87 -0
  151. package/components/ui/button.tsx +58 -0
  152. package/components/ui/card.tsx +32 -0
  153. package/components/ui/command.tsx +194 -0
  154. package/components/ui/dialog.tsx +158 -0
  155. package/components/ui/dropdown-menu.tsx +266 -0
  156. package/components/ui/hover-card.tsx +51 -0
  157. package/components/ui/input-group.tsx +158 -0
  158. package/components/ui/input.tsx +20 -0
  159. package/components/ui/scroll-area.tsx +16 -0
  160. package/components/ui/select.tsx +199 -0
  161. package/components/ui/separator.tsx +23 -0
  162. package/components/ui/sheet.tsx +92 -0
  163. package/components/ui/spinner.tsx +10 -0
  164. package/components/ui/tabs.tsx +113 -0
  165. package/components/ui/textarea.tsx +18 -0
  166. package/components/ui/tooltip.tsx +66 -0
  167. package/components/use-resolved-papyrus-theme.ts +47 -0
  168. package/infra/amplify-app-shell.js +234 -0
  169. package/infra/amplify-app-shell.ts +284 -0
  170. package/infra/build-specs.js +85 -0
  171. package/infra/build-specs.ts +131 -0
  172. package/infra/github-oidc-provider.js +24 -0
  173. package/infra/github-oidc-provider.ts +30 -0
  174. package/infra/index.js +15 -0
  175. package/infra/site-config.js +186 -0
  176. package/infra/site-config.ts +235 -0
  177. package/lib/amplify-outputs-guard.ts +24 -0
  178. package/lib/amplify-outputs-path.ts +16 -0
  179. package/lib/amplify-server-runtime.ts +63 -0
  180. package/lib/archive-data.ts +30 -0
  181. package/lib/archive-types.ts +15 -0
  182. package/lib/articles.ts +320 -0
  183. package/lib/cached-content-repository.ts +90 -0
  184. package/lib/category-dashboard.ts +37 -0
  185. package/lib/category-repository.ts +1907 -0
  186. package/lib/console-chat-client.ts +1316 -0
  187. package/lib/console-web-context.ts +118 -0
  188. package/lib/content-actions-client.ts +98 -0
  189. package/lib/content-actions-demo.ts +190 -0
  190. package/lib/content-load-timing.ts +23 -0
  191. package/lib/content-repository.ts +43 -0
  192. package/lib/content-source-context.ts +26 -0
  193. package/lib/content-types.ts +104 -0
  194. package/lib/define-site.js +3 -0
  195. package/lib/define-site.ts +21 -0
  196. package/lib/demo-amplify-outputs.ts +15 -0
  197. package/lib/dev-sandbox-editor-auth.ts +18 -0
  198. package/lib/doctrine.ts +128 -0
  199. package/lib/edition-routes.ts +155 -0
  200. package/lib/edition-sections.ts +111 -0
  201. package/lib/empty-edition-layout-plan.ts +109 -0
  202. package/lib/empty-theme.css +1 -0
  203. package/lib/excerpts.ts +8 -0
  204. package/lib/graphql-content-repository.ts +1140 -0
  205. package/lib/image-url.ts +10 -0
  206. package/lib/layout-plan.ts +981 -0
  207. package/lib/layout-scenarios.ts +564 -0
  208. package/lib/markus-body.ts +168 -0
  209. package/lib/markus-ir.ts +235 -0
  210. package/lib/markus-projection.ts +243 -0
  211. package/lib/markus-to-article.ts +93 -0
  212. package/lib/news-desk-session.ts +160 -0
  213. package/lib/newspaper-layout.ts +3459 -0
  214. package/lib/newsroom-articles.ts +325 -0
  215. package/lib/newsroom-assignments.ts +173 -0
  216. package/lib/newsroom-base-path.ts +48 -0
  217. package/lib/newsroom-card-layout.ts +102 -0
  218. package/lib/newsroom-category-drilldown.ts +133 -0
  219. package/lib/newsroom-demo-articles.ts +90 -0
  220. package/lib/newsroom-demo-dashboard.ts +267 -0
  221. package/lib/newsroom-demo-profile.ts +46 -0
  222. package/lib/newsroom-forum-routes.ts +153 -0
  223. package/lib/newsroom-index-filters.ts +202 -0
  224. package/lib/newsroom-knowledge-query-request.ts +51 -0
  225. package/lib/newsroom-list-selection.ts +42 -0
  226. package/lib/newsroom-media-backend.ts +142 -0
  227. package/lib/newsroom-media.ts +182 -0
  228. package/lib/newsroom-nav.ts +44 -0
  229. package/lib/newsroom-references.ts +116 -0
  230. package/lib/newsroom-sections.ts +52 -0
  231. package/lib/newsroom-topics.ts +118 -0
  232. package/lib/papyrus-object-kinds.ts +24 -0
  233. package/lib/papyrus-web-locations.ts +480 -0
  234. package/lib/pilobol-sample.ts +167 -0
  235. package/lib/presentation-footer.ts +92 -0
  236. package/lib/pretext-layout.ts +179 -0
  237. package/lib/public-placeholder-config.ts +102 -0
  238. package/lib/publication-items.ts +181 -0
  239. package/lib/reader-route-config.ts +1 -0
  240. package/lib/reader-static-params.ts +56 -0
  241. package/lib/reader-storage-url.ts +63 -0
  242. package/lib/reference-display.ts +74 -0
  243. package/lib/reference-policy.ts +97 -0
  244. package/lib/reference-source-preview.ts +175 -0
  245. package/lib/renderer-config.ts +50 -0
  246. package/lib/renderer.ts +87 -0
  247. package/lib/reporting-story-budget.ts +638 -0
  248. package/lib/semantic-graph.ts +554 -0
  249. package/lib/site-brand-tenant.ts +22 -0
  250. package/lib/site-brand.ts +194 -0
  251. package/lib/site-env.ts +52 -0
  252. package/lib/site-renderer.ts +17 -0
  253. package/lib/site-stack.ts +195 -0
  254. package/lib/staging-gate.ts +37 -0
  255. package/lib/staging-preview-object.ts +98 -0
  256. package/lib/themed-image.ts +30 -0
  257. package/lib/utils.ts +6 -0
  258. package/lib/video-mode.ts +42 -0
  259. package/lib/video-script.ts +63 -0
  260. package/middleware.ts +103 -0
  261. package/package.json +318 -1
  262. package/renderers/markus/index.ts +25 -0
  263. package/renderers/markus/stubs.tsx +33 -0
  264. package/renderers/pretext/article-page.tsx +169 -0
  265. package/renderers/pretext/index.ts +30 -0
  266. package/renderers/pretext/layouts.ts +16 -0
  267. package/renderers/pretext/presentation-shell.tsx +470 -0
  268. package/routes.manifest.json +208 -0
  269. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  270. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  271. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  272. package/scripts/videoml/bundle-common.mjs +129 -0
  273. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  274. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  275. package/scripts/videoml/preview/preview-timing.ts +42 -0
  276. package/scripts/videoml/preview.html +77 -0
  277. package/scripts/videoml/standard-entry.tsx +46 -0
  278. package/src/backend.js +1 -0
  279. package/src/backend.ts +3 -0
  280. package/src/sync.mjs +87 -0
  281. package/src/with-papyrus.d.mts +2 -0
  282. package/src/with-papyrus.mjs +60 -0
@@ -0,0 +1,981 @@
1
+ import { z } from "zod";
2
+ import {
3
+ type PublicationItem,
4
+ getPublicationItemImageAssets,
5
+ getPublicationItemText,
6
+ } from "./publication-items";
7
+
8
+ export const PAGE_PRESETS = [
9
+ "front.mosaic",
10
+ "page.regionStack",
11
+ "page.railMain",
12
+ "page.full",
13
+ ] as const;
14
+
15
+ export const REGION_TYPES = [
16
+ "stack",
17
+ "split",
18
+ "railMain",
19
+ "strip",
20
+ "fullPage",
21
+ ] as const;
22
+
23
+ export const BLOCK_TYPES = [
24
+ "articleFrame",
25
+ "itemFrame",
26
+ "mediaCluster",
27
+ "itemStack",
28
+ "promoStrip",
29
+ "adBlock",
30
+ "rule",
31
+ "masthead",
32
+ ] as const;
33
+
34
+ export const ARTICLE_FRAME_PRESETS = [
35
+ "front.teaser",
36
+ "article.standard",
37
+ "article.mediaInset",
38
+ "article.mediaPrelude",
39
+ ] as const;
40
+
41
+ export const MEDIA_CLUSTER_PRESETS = [
42
+ "media.triptych",
43
+ "media.mosaic",
44
+ ] as const;
45
+
46
+ export const AD_PRESETS = [
47
+ "ad.fullPage",
48
+ "ad.region",
49
+ ] as const;
50
+
51
+ export const RESPONSIVE_PLACEMENT_ANCHORS = [
52
+ "left",
53
+ "right",
54
+ "center",
55
+ "outer",
56
+ "inner",
57
+ "inline",
58
+ ] as const;
59
+
60
+ export const RESPONSIVE_VERTICAL_PLACEMENTS = [
61
+ "top",
62
+ "upperThird",
63
+ "middle",
64
+ "lowerThird",
65
+ ] as const;
66
+
67
+ export const RESPONSIVE_COLLAPSE_POLICIES = [
68
+ "inline",
69
+ "fullWidth",
70
+ "omit",
71
+ ] as const;
72
+
73
+ export const RESPONSIVE_CROP_POLICIES = [
74
+ "preserve",
75
+ "cropAllowed",
76
+ ] as const;
77
+
78
+ export const HEADLINE_SCALES = [
79
+ "banner",
80
+ "feature",
81
+ "standard",
82
+ "rail",
83
+ "brief",
84
+ ] as const;
85
+
86
+ export const EDITORIAL_PRIORITIES = [
87
+ "primary",
88
+ "secondary",
89
+ "tertiary",
90
+ "supporting",
91
+ ] as const;
92
+
93
+ export const FRONT_RESPONSIVE_LAYOUT_ORDERS = [
94
+ "plan",
95
+ "editorialPriority",
96
+ ] as const;
97
+
98
+ export type PagePresetId = (typeof PAGE_PRESETS)[number];
99
+ export type RegionType = (typeof REGION_TYPES)[number];
100
+ export type BlockType = (typeof BLOCK_TYPES)[number];
101
+ export type ArticleFramePresetId = (typeof ARTICLE_FRAME_PRESETS)[number];
102
+ export type MediaClusterPresetId = (typeof MEDIA_CLUSTER_PRESETS)[number];
103
+ export type AdPresetId = (typeof AD_PRESETS)[number];
104
+ export type ResponsivePlacementAnchor = (typeof RESPONSIVE_PLACEMENT_ANCHORS)[number];
105
+ export type ResponsiveVerticalPlacement = (typeof RESPONSIVE_VERTICAL_PLACEMENTS)[number];
106
+ export type ResponsiveCollapsePolicy = (typeof RESPONSIVE_COLLAPSE_POLICIES)[number];
107
+ export type ResponsiveCropPolicy = (typeof RESPONSIVE_CROP_POLICIES)[number];
108
+ export type HeadlineScaleId = (typeof HEADLINE_SCALES)[number];
109
+ export type EditorialPriorityId = (typeof EDITORIAL_PRIORITIES)[number];
110
+ export type FrontResponsiveLayoutOrder = (typeof FRONT_RESPONSIVE_LAYOUT_ORDERS)[number];
111
+
112
+ const ItemIdSchema = z.string().min(1);
113
+ const EmValueSchema = z
114
+ .string()
115
+ .regex(/^(?:\d+|\d*\.\d+)em$/, "must be an em value like 1em or 2.5em");
116
+
117
+ const ChromeTextSlotSchema = z
118
+ .object({
119
+ lineHeight: EmValueSchema.optional(),
120
+ paintHeight: EmValueSchema.optional(),
121
+ marginBefore: EmValueSchema.optional(),
122
+ marginAfter: EmValueSchema.optional(),
123
+ minHeight: EmValueSchema.optional(),
124
+ })
125
+ .strict();
126
+
127
+ const ArticleFrameChromeSchema = z
128
+ .object({
129
+ label: ChromeTextSlotSchema.optional(),
130
+ headline: ChromeTextSlotSchema.optional(),
131
+ deck: ChromeTextSlotSchema.optional(),
132
+ byline: ChromeTextSlotSchema.optional(),
133
+ caption: ChromeTextSlotSchema.optional(),
134
+ pullQuote: ChromeTextSlotSchema.optional(),
135
+ jumpLine: ChromeTextSlotSchema.optional(),
136
+ })
137
+ .strict();
138
+
139
+ const ArticleFrameTypographySchema = z
140
+ .object({
141
+ headlineScale: z.enum(HEADLINE_SCALES).optional(),
142
+ })
143
+ .strict();
144
+
145
+ const SpanPolicySchema = z
146
+ .object({
147
+ min: z.number().int().positive(),
148
+ preferred: z.number().int().positive(),
149
+ max: z.number().int().positive(),
150
+ })
151
+ .strict()
152
+ .superRefine((value, context) => {
153
+ if (value.min > value.preferred) {
154
+ context.addIssue({
155
+ code: z.ZodIssueCode.custom,
156
+ path: ["min"],
157
+ message: "min must be less than or equal to preferred",
158
+ });
159
+ }
160
+ if (value.preferred > value.max) {
161
+ context.addIssue({
162
+ code: z.ZodIssueCode.custom,
163
+ path: ["preferred"],
164
+ message: "preferred must be less than or equal to max",
165
+ });
166
+ }
167
+ });
168
+
169
+ const ContentRequirementsSchema = z
170
+ .object({
171
+ minWords: z.number().int().positive().optional(),
172
+ maxWords: z.number().int().positive().optional(),
173
+ minImages: z.number().int().nonnegative().optional(),
174
+ imageRole: z.string().min(1).optional(),
175
+ itemType: z.enum(["article", "brief", "correction", "promo", "ad", "sectionHeader"]).optional(),
176
+ })
177
+ .strict()
178
+ .superRefine((value, context) => {
179
+ if (value.minWords !== undefined && value.maxWords !== undefined && value.minWords > value.maxWords) {
180
+ context.addIssue({
181
+ code: z.ZodIssueCode.custom,
182
+ path: ["minWords"],
183
+ message: "minWords must be less than or equal to maxWords",
184
+ });
185
+ }
186
+ });
187
+
188
+ const LocalGridSchema = z
189
+ .object({
190
+ columns: SpanPolicySchema.default({ min: 1, preferred: 6, max: 6 }),
191
+ })
192
+ .strict();
193
+
194
+ const PageGridSchema = z
195
+ .object({
196
+ columns: SpanPolicySchema.default({ min: 1, preferred: 6, max: 6 }),
197
+ })
198
+ .strict();
199
+
200
+ const RegionSizeSchema = z
201
+ .object({
202
+ ratio: z.number().positive().optional(),
203
+ minHeight: z.number().nonnegative().optional(),
204
+ preferredHeight: z.number().nonnegative().optional(),
205
+ maxHeight: z.number().nonnegative().optional(),
206
+ shrinkToContent: z.boolean().default(false),
207
+ })
208
+ .strict();
209
+
210
+ const ArticleFrameSizeSchema = z
211
+ .object({
212
+ defaultRows: z.number().int().positive().optional(),
213
+ shrinkToContent: z.boolean().default(false),
214
+ })
215
+ .strict();
216
+
217
+ const FrontResponsiveSlotSchema = z
218
+ .object({
219
+ blockId: z.string().min(1).optional(),
220
+ editorialPriority: z.enum(EDITORIAL_PRIORITIES).optional(),
221
+ priorityOccurrence: z.number().int().positive().default(1),
222
+ columnStart: z.number().int().positive(),
223
+ columnSpan: z.number().int().positive(),
224
+ rowStart: z.number().int().positive(),
225
+ rowSpan: z.number().int().positive().default(1),
226
+ })
227
+ .strict()
228
+ .superRefine((value, context) => {
229
+ if (!value.blockId && !value.editorialPriority) {
230
+ context.addIssue({
231
+ code: z.ZodIssueCode.custom,
232
+ path: ["blockId"],
233
+ message: "slot must match by blockId or editorialPriority",
234
+ });
235
+ }
236
+ });
237
+
238
+ const FrontResponsiveOverflowSchema = z
239
+ .object({
240
+ columnSpan: z.union([z.number().int().positive(), z.literal("full")]).default("full"),
241
+ rowSpan: z.number().int().positive().default(1),
242
+ })
243
+ .strict();
244
+
245
+ const FrontResponsiveLayoutSchema = z
246
+ .object({
247
+ minColumns: z.number().int().positive(),
248
+ maxColumns: z.number().int().positive(),
249
+ order: z.enum(FRONT_RESPONSIVE_LAYOUT_ORDERS).default("plan"),
250
+ slots: z.array(FrontResponsiveSlotSchema).default([]),
251
+ overflow: FrontResponsiveOverflowSchema.default({}),
252
+ })
253
+ .strict()
254
+ .superRefine((value, context) => {
255
+ if (value.minColumns > value.maxColumns) {
256
+ context.addIssue({
257
+ code: z.ZodIssueCode.custom,
258
+ path: ["minColumns"],
259
+ message: "minColumns must be less than or equal to maxColumns",
260
+ });
261
+ }
262
+ });
263
+
264
+ const PlacementSchema = z
265
+ .object({
266
+ anchor: z.enum(RESPONSIVE_PLACEMENT_ANCHORS).default("left"),
267
+ columnStart: z.number().int().positive().optional(),
268
+ span: SpanPolicySchema,
269
+ spanOverrides: z.record(z.string(), z.number().int().positive()).optional(),
270
+ vertical: z.enum(RESPONSIVE_VERTICAL_PLACEMENTS).default("upperThird"),
271
+ collapse: z.enum(RESPONSIVE_COLLAPSE_POLICIES).default("inline"),
272
+ crop: z.enum(RESPONSIVE_CROP_POLICIES).default("preserve"),
273
+ wrapsText: z.boolean().default(true),
274
+ })
275
+ .strict();
276
+
277
+ const MediaSpecSchema = z
278
+ .object({
279
+ required: z.boolean().default(false),
280
+ assetRole: z.string().min(1).optional(),
281
+ placement: PlacementSchema,
282
+ count: SpanPolicySchema.optional(),
283
+ pattern: z.enum(MEDIA_CLUSTER_PRESETS).optional(),
284
+ })
285
+ .strict();
286
+
287
+ const PullQuoteSpecSchema = z
288
+ .object({
289
+ required: z.boolean().default(false),
290
+ placements: z.array(PlacementSchema).min(1),
291
+ })
292
+ .strict();
293
+
294
+ const ArticleFrameCompositionSlotSchema = z
295
+ .object({
296
+ slot: z.enum(["label", "headline", "deck", "byline", "media", "pullQuote"]),
297
+ mediaIndex: z.number().int().nonnegative().optional(),
298
+ placement: PlacementSchema,
299
+ })
300
+ .strict();
301
+
302
+ const ArticleFrameCompositionSchema = z
303
+ .object({
304
+ title: z.array(ArticleFrameCompositionSlotSchema).default([]),
305
+ lead: z.array(ArticleFrameCompositionSlotSchema).default([]),
306
+ })
307
+ .strict();
308
+
309
+ const CutPolicySchema = z
310
+ .object({
311
+ bodyDepthRows: z.number().int().positive().optional(),
312
+ maxBodyLines: z.number().int().positive().optional(),
313
+ jumpTargetPage: z.number().int().positive().optional(),
314
+ })
315
+ .strict();
316
+
317
+ const ArticleFrameBlockSchema = z
318
+ .object({
319
+ id: z.string().min(1),
320
+ type: z.literal("articleFrame"),
321
+ presetId: z.enum(ARTICLE_FRAME_PRESETS),
322
+ itemId: ItemIdSchema,
323
+ flowKey: z.string().min(1).optional(),
324
+ startCursor: z.enum(["beginning", "current"]).default("current"),
325
+ role: z.string().min(1).optional(),
326
+ editorialPriority: z.enum(EDITORIAL_PRIORITIES).default("tertiary"),
327
+ localGrid: LocalGridSchema.optional(),
328
+ span: SpanPolicySchema.optional(),
329
+ media: z.array(MediaSpecSchema).default([]),
330
+ pullQuote: PullQuoteSpecSchema.optional(),
331
+ size: ArticleFrameSizeSchema.optional(),
332
+ chrome: ArticleFrameChromeSchema.optional(),
333
+ typography: ArticleFrameTypographySchema.optional(),
334
+ composition: ArticleFrameCompositionSchema.optional(),
335
+ cutPolicy: CutPolicySchema.optional(),
336
+ requires: ContentRequirementsSchema.optional(),
337
+ })
338
+ .strict();
339
+
340
+ const ItemFrameBlockSchema = z
341
+ .object({
342
+ id: z.string().min(1),
343
+ type: z.literal("itemFrame"),
344
+ itemId: ItemIdSchema,
345
+ localGrid: LocalGridSchema.optional(),
346
+ span: SpanPolicySchema.optional(),
347
+ media: z.array(MediaSpecSchema).default([]),
348
+ requires: ContentRequirementsSchema.optional(),
349
+ })
350
+ .strict();
351
+
352
+ const MediaClusterBlockSchema = z
353
+ .object({
354
+ id: z.string().min(1),
355
+ type: z.literal("mediaCluster"),
356
+ itemId: ItemIdSchema,
357
+ presetId: z.enum(MEDIA_CLUSTER_PRESETS),
358
+ assetRole: z.string().min(1).optional(),
359
+ count: SpanPolicySchema.default({ min: 0, preferred: 3, max: 5 }),
360
+ caption: z.string().optional(),
361
+ required: z.boolean().default(false),
362
+ })
363
+ .strict();
364
+
365
+ const ItemStackBlockSchema = z
366
+ .object({
367
+ id: z.string().min(1),
368
+ type: z.literal("itemStack"),
369
+ itemIds: z.array(ItemIdSchema).min(1),
370
+ title: z.string().optional(),
371
+ })
372
+ .strict();
373
+
374
+ const PromoStripBlockSchema = z
375
+ .object({
376
+ id: z.string().min(1),
377
+ type: z.literal("promoStrip"),
378
+ itemIds: z.array(ItemIdSchema).min(1),
379
+ })
380
+ .strict();
381
+
382
+ const AdBlockSchema = z
383
+ .object({
384
+ id: z.string().min(1),
385
+ type: z.literal("adBlock"),
386
+ presetId: z.enum(AD_PRESETS),
387
+ itemId: ItemIdSchema.optional(),
388
+ imageUrl: z.string().url().optional(),
389
+ label: z.string().min(1).optional(),
390
+ required: z.boolean().default(false),
391
+ })
392
+ .strict();
393
+
394
+ const RuleBlockSchema = z
395
+ .object({
396
+ id: z.string().min(1),
397
+ type: z.literal("rule"),
398
+ })
399
+ .strict();
400
+
401
+ const MastheadBlockSchema = z
402
+ .object({
403
+ id: z.string().min(1),
404
+ type: z.literal("masthead"),
405
+ })
406
+ .strict();
407
+
408
+ const LayoutBlockSchema = z.discriminatedUnion("type", [
409
+ ArticleFrameBlockSchema,
410
+ ItemFrameBlockSchema,
411
+ MediaClusterBlockSchema,
412
+ ItemStackBlockSchema,
413
+ PromoStripBlockSchema,
414
+ AdBlockSchema,
415
+ RuleBlockSchema,
416
+ MastheadBlockSchema,
417
+ ]);
418
+
419
+ const LayoutRegionSchema = z
420
+ .object({
421
+ id: z.string().min(1),
422
+ type: z.enum(REGION_TYPES),
423
+ role: z.string().min(1).optional(),
424
+ size: RegionSizeSchema.optional(),
425
+ localGrid: LocalGridSchema.optional(),
426
+ responsiveLayouts: z.array(FrontResponsiveLayoutSchema).optional(),
427
+ blocks: z.array(LayoutBlockSchema).min(1),
428
+ })
429
+ .strict();
430
+
431
+ const LayoutPageSchema = z
432
+ .object({
433
+ id: z.string().min(1).optional(),
434
+ pageNumber: z.number().int().positive(),
435
+ presetId: z.enum(PAGE_PRESETS),
436
+ grid: PageGridSchema.optional(),
437
+ regions: z.array(LayoutRegionSchema).min(1),
438
+ })
439
+ .strict();
440
+
441
+ const EditionLayoutPlanSchema = z
442
+ .object({
443
+ pages: z.array(LayoutPageSchema).min(1),
444
+ })
445
+ .strict();
446
+
447
+ export type ContentRequirements = z.infer<typeof ContentRequirementsSchema>;
448
+ export type EmValue = z.infer<typeof EmValueSchema>;
449
+ export type ChromeTextSlotSpec = z.infer<typeof ChromeTextSlotSchema>;
450
+ export type ArticleFrameChromeSpec = z.infer<typeof ArticleFrameChromeSchema>;
451
+ export type ArticleFrameTypographySpec = z.infer<typeof ArticleFrameTypographySchema>;
452
+ export type ResponsiveSpanPolicy = z.infer<typeof SpanPolicySchema>;
453
+ export type ResponsivePlacementSpec = z.infer<typeof PlacementSchema>;
454
+ export type LayoutMediaSpec = z.infer<typeof MediaSpecSchema>;
455
+ export type LayoutPullQuoteSpec = z.infer<typeof PullQuoteSpecSchema>;
456
+ export type ArticleFrameSizeSpec = z.infer<typeof ArticleFrameSizeSchema>;
457
+ export type ArticleFrameCompositionSlotSpec = z.infer<typeof ArticleFrameCompositionSlotSchema>;
458
+ export type ArticleFrameCompositionSpec = z.infer<typeof ArticleFrameCompositionSchema>;
459
+ export type FrontResponsiveSlotSpec = z.infer<typeof FrontResponsiveSlotSchema>;
460
+ export type FrontResponsiveLayoutSpec = z.infer<typeof FrontResponsiveLayoutSchema>;
461
+ export type ArticleFrameBlockSpec = z.infer<typeof ArticleFrameBlockSchema>;
462
+ export type ItemFrameBlockSpec = z.infer<typeof ItemFrameBlockSchema>;
463
+ export type MediaClusterBlockSpec = z.infer<typeof MediaClusterBlockSchema>;
464
+ export type ItemStackBlockSpec = z.infer<typeof ItemStackBlockSchema>;
465
+ export type PromoStripBlockSpec = z.infer<typeof PromoStripBlockSchema>;
466
+ export type AdBlockSpec = z.infer<typeof AdBlockSchema>;
467
+ export type LayoutBlockSpec = z.infer<typeof LayoutBlockSchema>;
468
+ export type LayoutRegionSpec = z.infer<typeof LayoutRegionSchema>;
469
+ export type LayoutPageSpec = z.infer<typeof LayoutPageSchema>;
470
+ export type EditionLayoutPlan = z.infer<typeof EditionLayoutPlanSchema>;
471
+
472
+ export function createDefaultEditionLayoutPlan(itemIds: string[]): EditionLayoutPlan {
473
+ const frontItemIds = getDefaultFrontItemOrder(itemIds);
474
+ const frontBlocks = frontItemIds.map((itemId, index) => ({
475
+ id: `front-${itemId}`,
476
+ type: "articleFrame" as const,
477
+ presetId: "front.teaser" as const,
478
+ itemId,
479
+ flowKey: itemId,
480
+ startCursor: "beginning" as const,
481
+ role: getDefaultFrontRole(index),
482
+ editorialPriority: getDefaultFrontEditorialPriority(index),
483
+ typography: { headlineScale: getDefaultFrontHeadlineScale(index) },
484
+ span: getDefaultFrontSpan(index),
485
+ localGrid: index === 1 ? { columns: { min: 1, preferred: 4, max: 4 } } : undefined,
486
+ media: index === 1
487
+ ? [
488
+ {
489
+ required: true,
490
+ assetRole: "lead",
491
+ placement: {
492
+ anchor: "right",
493
+ span: { min: 1, preferred: 2, max: 2 },
494
+ vertical: "top",
495
+ collapse: "inline",
496
+ crop: "preserve",
497
+ wrapsText: true,
498
+ },
499
+ },
500
+ ]
501
+ : [],
502
+ composition: index === 1
503
+ ? {
504
+ title: [
505
+ {
506
+ slot: "label",
507
+ placement: {
508
+ columnStart: 1,
509
+ span: { min: 1, preferred: 2, max: 2 },
510
+ vertical: "top",
511
+ collapse: "inline",
512
+ crop: "preserve",
513
+ wrapsText: false,
514
+ },
515
+ },
516
+ {
517
+ slot: "headline",
518
+ placement: {
519
+ columnStart: 1,
520
+ span: { min: 1, preferred: 2, max: 2 },
521
+ vertical: "top",
522
+ collapse: "inline",
523
+ crop: "preserve",
524
+ wrapsText: false,
525
+ },
526
+ },
527
+ ],
528
+ lead: [
529
+ {
530
+ slot: "deck",
531
+ placement: {
532
+ columnStart: 1,
533
+ span: { min: 1, preferred: 2, max: 2 },
534
+ vertical: "top",
535
+ collapse: "inline",
536
+ crop: "preserve",
537
+ wrapsText: true,
538
+ },
539
+ },
540
+ {
541
+ slot: "byline",
542
+ placement: {
543
+ columnStart: 1,
544
+ span: { min: 1, preferred: 2, max: 2 },
545
+ vertical: "top",
546
+ collapse: "inline",
547
+ crop: "preserve",
548
+ wrapsText: true,
549
+ },
550
+ },
551
+ {
552
+ slot: "media",
553
+ mediaIndex: 0,
554
+ placement: {
555
+ anchor: "right",
556
+ span: { min: 1, preferred: 2, max: 2 },
557
+ vertical: "top",
558
+ collapse: "inline",
559
+ crop: "preserve",
560
+ wrapsText: true,
561
+ },
562
+ },
563
+ ],
564
+ }
565
+ : undefined,
566
+ cutPolicy: getDefaultCutPolicy(itemId),
567
+ }));
568
+
569
+ const plan: EditionLayoutPlan = normalizeEditionLayoutPlan({
570
+ pages: [
571
+ {
572
+ id: "page-1",
573
+ pageNumber: 1,
574
+ presetId: "front.mosaic",
575
+ grid: { columns: { min: 1, preferred: 6, max: 6 } },
576
+ regions: [
577
+ {
578
+ id: "front-page-news",
579
+ type: "fullPage",
580
+ localGrid: { columns: { min: 1, preferred: 6, max: 6 } },
581
+ responsiveLayouts: getDefaultFrontResponsiveLayouts(),
582
+ blocks: frontBlocks,
583
+ },
584
+ ],
585
+ },
586
+ {
587
+ id: "page-2",
588
+ pageNumber: 2,
589
+ presetId: "page.regionStack",
590
+ grid: { columns: { min: 1, preferred: 6, max: 6 } },
591
+ regions: [
592
+ {
593
+ id: "harbor-continuation",
594
+ type: "fullPage",
595
+ size: { shrinkToContent: true },
596
+ blocks: [
597
+ {
598
+ id: "agent-procedure-patterns-page-2",
599
+ type: "articleFrame",
600
+ presetId: "article.mediaInset",
601
+ itemId: "agent-procedure-patterns",
602
+ flowKey: "agent-procedure-patterns",
603
+ startCursor: "current",
604
+ role: "primary",
605
+ localGrid: { columns: { min: 2, preferred: 6, max: 6 } },
606
+ media: [
607
+ {
608
+ required: true,
609
+ assetRole: "continuationInset",
610
+ placement: {
611
+ anchor: "center",
612
+ span: { min: 1, preferred: 2, max: 3 },
613
+ vertical: "upperThird",
614
+ collapse: "inline",
615
+ crop: "preserve",
616
+ wrapsText: true,
617
+ },
618
+ },
619
+ ],
620
+ pullQuote: {
621
+ required: false,
622
+ placements: [
623
+ {
624
+ anchor: "right",
625
+ span: { min: 1, preferred: 1, max: 2 },
626
+ vertical: "middle",
627
+ collapse: "omit",
628
+ crop: "preserve",
629
+ wrapsText: true,
630
+ },
631
+ ],
632
+ },
633
+ },
634
+ ],
635
+ },
636
+ ],
637
+ },
638
+ {
639
+ id: "page-3",
640
+ pageNumber: 3,
641
+ presetId: "page.regionStack",
642
+ grid: { columns: { min: 1, preferred: 6, max: 6 } },
643
+ regions: [
644
+ {
645
+ id: "schools-reading-lab-tail",
646
+ type: "stack",
647
+ role: "top",
648
+ size: { ratio: 0.5 },
649
+ blocks: [
650
+ {
651
+ id: "schools-reading-lab-page-3",
652
+ type: "articleFrame",
653
+ presetId: "article.mediaInset",
654
+ itemId: "schools-reading-lab",
655
+ flowKey: "schools-reading-lab",
656
+ startCursor: "current",
657
+ role: "top",
658
+ localGrid: { columns: { min: 4, preferred: 6, max: 6 } },
659
+ media: [
660
+ {
661
+ required: false,
662
+ assetRole: "continuationInset",
663
+ placement: {
664
+ anchor: "right",
665
+ span: { min: 1, preferred: 2, max: 2 },
666
+ vertical: "top",
667
+ collapse: "inline",
668
+ crop: "preserve",
669
+ wrapsText: true,
670
+ },
671
+ },
672
+ ],
673
+ pullQuote: {
674
+ required: false,
675
+ placements: [
676
+ {
677
+ anchor: "left",
678
+ span: { min: 1, preferred: 1, max: 2 },
679
+ vertical: "middle",
680
+ collapse: "omit",
681
+ crop: "preserve",
682
+ wrapsText: true,
683
+ },
684
+ ],
685
+ },
686
+ },
687
+ ],
688
+ },
689
+ {
690
+ id: "market-hall-tail",
691
+ type: "stack",
692
+ role: "bottom",
693
+ size: { ratio: 0.5 },
694
+ blocks: [
695
+ {
696
+ id: "market-hall-page-3",
697
+ type: "articleFrame",
698
+ presetId: "article.mediaInset",
699
+ itemId: "market-hall",
700
+ flowKey: "market-hall",
701
+ startCursor: "current",
702
+ role: "bottom",
703
+ localGrid: { columns: { min: 2, preferred: 6, max: 6 } },
704
+ media: [
705
+ {
706
+ required: false,
707
+ assetRole: "continuationInset",
708
+ placement: {
709
+ anchor: "center",
710
+ span: { min: 2, preferred: 2, max: 2 },
711
+ vertical: "top",
712
+ collapse: "inline",
713
+ crop: "preserve",
714
+ wrapsText: true,
715
+ },
716
+ },
717
+ ],
718
+ pullQuote: {
719
+ required: false,
720
+ placements: [
721
+ {
722
+ anchor: "right",
723
+ span: { min: 1, preferred: 1, max: 2 },
724
+ vertical: "middle",
725
+ collapse: "omit",
726
+ crop: "preserve",
727
+ wrapsText: true,
728
+ },
729
+ ],
730
+ },
731
+ },
732
+ ],
733
+ },
734
+ ],
735
+ },
736
+ ],
737
+ }, "default layoutPlan");
738
+
739
+ return plan;
740
+ }
741
+
742
+ function getDefaultFrontResponsiveLayouts(): FrontResponsiveLayoutSpec[] {
743
+ return [
744
+ {
745
+ minColumns: 4,
746
+ maxColumns: 4,
747
+ order: "editorialPriority",
748
+ slots: [
749
+ {
750
+ editorialPriority: "primary",
751
+ priorityOccurrence: 1,
752
+ columnStart: 1,
753
+ columnSpan: 4,
754
+ rowStart: 1,
755
+ rowSpan: 1,
756
+ },
757
+ {
758
+ editorialPriority: "secondary",
759
+ priorityOccurrence: 1,
760
+ columnStart: 1,
761
+ columnSpan: 2,
762
+ rowStart: 2,
763
+ rowSpan: 1,
764
+ },
765
+ {
766
+ editorialPriority: "secondary",
767
+ priorityOccurrence: 2,
768
+ columnStart: 3,
769
+ columnSpan: 2,
770
+ rowStart: 2,
771
+ rowSpan: 1,
772
+ },
773
+ ],
774
+ overflow: { columnSpan: 2, rowSpan: 1 },
775
+ },
776
+ {
777
+ minColumns: 1,
778
+ maxColumns: 3,
779
+ order: "editorialPriority",
780
+ slots: [],
781
+ overflow: { columnSpan: "full", rowSpan: 1 },
782
+ },
783
+ ];
784
+ }
785
+
786
+ export function normalizeEditionLayoutPlan(value: unknown, label = "Edition.layoutPlan"): EditionLayoutPlan {
787
+ const parsed = parseMaybeJson(value, label);
788
+ const result = EditionLayoutPlanSchema.safeParse(parsed);
789
+ if (!result.success) {
790
+ throw new Error(formatZodError(label, result.error));
791
+ }
792
+ return result.data;
793
+ }
794
+
795
+ export function validateEditionLayoutPlanForItems(
796
+ layoutPlan: EditionLayoutPlan,
797
+ items: PublicationItem[],
798
+ label = "Edition.layoutPlan",
799
+ ): EditionLayoutPlan {
800
+ const itemsBySlug = new Map(items.map((item) => [item.slug, item]));
801
+ const pageNumbers = new Set(layoutPlan.pages.map((page) => page.pageNumber));
802
+ const seenPageNumbers = new Set<number>();
803
+ const flowStarts = new Map<string, "beginning" | "current">();
804
+
805
+ for (const page of layoutPlan.pages) {
806
+ if (seenPageNumbers.has(page.pageNumber)) throw new Error(`${label}.pages contains duplicate pageNumber ${page.pageNumber}`);
807
+ seenPageNumbers.add(page.pageNumber);
808
+ for (const region of page.regions) {
809
+ for (const block of region.blocks) {
810
+ validateBlock(block, itemsBySlug, pageNumbers, flowStarts, `${label}.pages[${page.pageNumber}].regions.${region.id}`);
811
+ }
812
+ }
813
+ }
814
+
815
+ return layoutPlan;
816
+ }
817
+
818
+ function validateBlock(
819
+ block: LayoutBlockSpec,
820
+ itemsBySlug: Map<string, PublicationItem>,
821
+ pageNumbers: Set<number>,
822
+ flowStarts: Map<string, "beginning" | "current">,
823
+ label: string,
824
+ ): void {
825
+ if (block.type === "rule" || block.type === "masthead") return;
826
+ if (block.type === "itemStack" || block.type === "promoStrip") {
827
+ for (const itemId of block.itemIds) requireKnownItem(itemId, itemsBySlug, label);
828
+ return;
829
+ }
830
+ if (block.type === "adBlock") {
831
+ if (block.itemId) requireKnownItem(block.itemId, itemsBySlug, label);
832
+ if (block.required && !block.itemId && !block.imageUrl && !block.label) {
833
+ throw new Error(`${label}.${block.id} requires an ad item, imageUrl, or label`);
834
+ }
835
+ return;
836
+ }
837
+
838
+ const item = requireKnownItem(block.itemId, itemsBySlug, label);
839
+ if ("requires" in block) validateItemRequirements(item, block.requires, `${label}.${block.id}.requires`);
840
+
841
+ if (block.type === "articleFrame") {
842
+ if (item.type !== "article") throw new Error(`${label}.${block.id} articleFrame requires an article item; ${block.itemId} is ${item.type}`);
843
+ const flowKey = block.flowKey ?? block.itemId;
844
+ if (block.startCursor === "beginning") {
845
+ flowStarts.set(flowKey, "beginning");
846
+ } else if (!flowStarts.has(flowKey)) {
847
+ throw new Error(`${label}.${block.id} starts flow ${flowKey} at current before any beginning block`);
848
+ }
849
+ if (block.cutPolicy?.jumpTargetPage && !pageNumbers.has(block.cutPolicy.jumpTargetPage)) {
850
+ throw new Error(`${label}.${block.id} points to missing jumpTargetPage ${block.cutPolicy.jumpTargetPage}`);
851
+ }
852
+ for (const media of block.media) validateMediaSpec(item, media, `${label}.${block.id}.media`);
853
+ validateArticleFramePlacementGeometry(block, `${label}.${block.id}`);
854
+ if (block.pullQuote?.required && !item.pullQuotes?.[0]) {
855
+ throw new Error(`${label}.${block.id}.pullQuote requires an editorial pull quote`);
856
+ }
857
+ }
858
+
859
+ if (block.type === "mediaCluster") {
860
+ const assets = findAssetsByRole(item, block.assetRole);
861
+ if (block.required && assets.length < block.count.min) {
862
+ throw new Error(`${label}.${block.id} requires at least ${block.count.min} media assets`);
863
+ }
864
+ }
865
+ }
866
+
867
+ function validateMediaSpec(item: PublicationItem, media: LayoutMediaSpec, label: string): void {
868
+ const matchingAssets = findAssetsByRole(item, media.assetRole);
869
+ if (media.required && matchingAssets.length === 0) {
870
+ throw new Error(`${label} requires an image asset${media.assetRole ? ` with role ${media.assetRole}` : ""}`);
871
+ }
872
+ }
873
+
874
+ function validateArticleFramePlacementGeometry(block: ArticleFrameBlockSpec, label: string): void {
875
+ const maxColumns = block.localGrid?.columns.max ?? block.span?.max ?? block.span?.preferred ?? 6;
876
+ block.media.forEach((media, index) => validatePlacementColumnStart(media.placement, maxColumns, `${label}.media[${index}].placement`));
877
+ block.pullQuote?.placements.forEach((placement, index) => (
878
+ validatePlacementColumnStart(placement, maxColumns, `${label}.pullQuote.placements[${index}]`)
879
+ ));
880
+ block.composition?.title.forEach((slot, index) => (
881
+ validatePlacementColumnStart(slot.placement, maxColumns, `${label}.composition.title[${index}].placement`)
882
+ ));
883
+ block.composition?.lead.forEach((slot, index) => (
884
+ validatePlacementColumnStart(slot.placement, maxColumns, `${label}.composition.lead[${index}].placement`)
885
+ ));
886
+ }
887
+
888
+ function validatePlacementColumnStart(placement: ResponsivePlacementSpec, maxColumns: number, label: string): void {
889
+ if (!placement.columnStart) return;
890
+ const lastColumn = placement.columnStart + placement.span.max - 1;
891
+ if (lastColumn > maxColumns) {
892
+ throw new Error(`${label} columnStart ${placement.columnStart} with max span ${placement.span.max} exceeds ${maxColumns} local columns`);
893
+ }
894
+ }
895
+
896
+ function validateItemRequirements(item: PublicationItem, requirements: ContentRequirements | undefined, label: string): void {
897
+ if (!requirements) return;
898
+ if (requirements.itemType && item.type !== requirements.itemType) {
899
+ throw new Error(`${label} requires item type ${requirements.itemType}; ${item.slug} is ${item.type}`);
900
+ }
901
+ const wordCount = getPublicationItemText(item).split(/\s+/).filter(Boolean).length;
902
+ const assets = getPublicationItemImageAssets(item);
903
+ if (requirements.minWords !== undefined && wordCount < requirements.minWords) {
904
+ throw new Error(`${label} requires at least ${requirements.minWords} words; ${item.slug} has ${wordCount}`);
905
+ }
906
+ if (requirements.maxWords !== undefined && wordCount > requirements.maxWords) {
907
+ throw new Error(`${label} allows at most ${requirements.maxWords} words; ${item.slug} has ${wordCount}`);
908
+ }
909
+ if (requirements.minImages !== undefined && assets.length < requirements.minImages) {
910
+ throw new Error(`${label} requires at least ${requirements.minImages} images; ${item.slug} has ${assets.length}`);
911
+ }
912
+ if (requirements.imageRole && findAssetsByRole(item, requirements.imageRole).length === 0) {
913
+ throw new Error(`${label} requires an image with role ${requirements.imageRole}`);
914
+ }
915
+ }
916
+
917
+ function findAssetsByRole(item: PublicationItem, role: string | undefined) {
918
+ const assets = getPublicationItemImageAssets(item);
919
+ if (!role) return assets;
920
+ return assets.filter((asset) => asset.roles?.some((assetRole) => assetRole === role));
921
+ }
922
+
923
+ function requireKnownItem(itemId: string, itemsBySlug: Map<string, PublicationItem>, label: string): PublicationItem {
924
+ const item = itemsBySlug.get(itemId);
925
+ if (!item) throw new Error(`${label} references missing item ${itemId}`);
926
+ return item;
927
+ }
928
+
929
+ function getDefaultFrontSpan(index: number): ResponsiveSpanPolicy {
930
+ const preferred = [1, 4, 1, 2, 2, 2][index] ?? 1;
931
+ return { min: 1, preferred, max: preferred };
932
+ }
933
+
934
+ function getDefaultFrontRole(index: number): string {
935
+ if (index === 1) return "feature";
936
+ if (index === 0 || index === 2) return "rail";
937
+ return "standard";
938
+ }
939
+
940
+ function getDefaultFrontHeadlineScale(index: number): HeadlineScaleId {
941
+ if (index === 1) return "feature";
942
+ if (index === 0 || index === 2) return "rail";
943
+ return "standard";
944
+ }
945
+
946
+ function getDefaultFrontEditorialPriority(index: number): EditorialPriorityId {
947
+ if (index === 1) return "primary";
948
+ if (index === 0 || index === 2) return "secondary";
949
+ return "tertiary";
950
+ }
951
+
952
+ function getDefaultFrontItemOrder(itemIds: string[]): string[] {
953
+ if (itemIds.length < 3) return itemIds;
954
+ return [itemIds[1], itemIds[0], itemIds[2], ...itemIds.slice(3)];
955
+ }
956
+
957
+ function getDefaultCutPolicy(itemId: string): ArticleFrameBlockSpec["cutPolicy"] | undefined {
958
+ if (itemId === "agent-procedure-patterns") return { maxBodyLines: 22, jumpTargetPage: 2 };
959
+ if (itemId === "schools-reading-lab") return { maxBodyLines: 16, jumpTargetPage: 3 };
960
+ if (itemId === "market-hall") return { maxBodyLines: 14, jumpTargetPage: 3 };
961
+ return undefined;
962
+ }
963
+
964
+ function parseMaybeJson(value: unknown, label: string): unknown {
965
+ if (typeof value !== "string") {
966
+ if (value === undefined || value === null) throw new Error(`${label} is required`);
967
+ return value;
968
+ }
969
+ if (!value.trim()) throw new Error(`${label} is required`);
970
+ try {
971
+ return JSON.parse(value);
972
+ } catch (error) {
973
+ throw new Error(`${label} must be valid JSON: ${error instanceof Error ? error.message : String(error)}`);
974
+ }
975
+ }
976
+
977
+ function formatZodError(label: string, error: z.ZodError): string {
978
+ return `${label} is invalid:\n${error.issues
979
+ .map((issue) => `- ${[label, ...issue.path].join(".")}: ${issue.message}`)
980
+ .join("\n")}`;
981
+ }