@anthusai/papyrus 1.3.0 → 1.4.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 (141) hide show
  1. package/amplify/data/resource.js +134 -3
  2. package/amplify/data/resource.ts +145 -3
  3. package/amplify/data/schema.js +131 -0
  4. package/amplify/data/schema.ts +142 -0
  5. package/amplify/functions/category-action/handler.ts +272 -20
  6. package/amplify/functions/console-chat-responder/src/main.rs +1 -1
  7. package/amplify/functions/content-actions/handler.py +23 -0
  8. package/amplify/functions/content-actions/requirements.txt +1 -1
  9. package/amplify/functions/content-actions/resource.js +1 -1
  10. package/amplify/functions/content-actions/resource.ts +1 -1
  11. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  12. package/amplify/functions/renditions/requirements.txt +1 -1
  13. package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
  14. package/amplify/functions/shared/curation-delegation.ts +115 -0
  15. package/amplify/functions/shared/python-bundle.js +3 -3
  16. package/amplify/functions/shared/python-bundle.ts +3 -3
  17. package/amplify/functions/shared/reference-curation-metadata.ts +51 -0
  18. package/amplify/functions/shared/slack-events.ts +2 -2
  19. package/amplify/publication/renditions-function.js +1 -1
  20. package/amplify/publication/renditions-function.ts +1 -1
  21. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
  22. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
  23. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
  24. package/app/[year]/[month]/[day]/page.tsx +3 -0
  25. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
  26. package/app/api/archive/editions/route.ts +2 -0
  27. package/app/api/auth/prospectus/accounts/route.ts +9 -0
  28. package/app/api/local-preview/derive/route.ts +28 -0
  29. package/app/api/local-preview/media/route.ts +25 -0
  30. package/app/archive/page.tsx +3 -0
  31. package/app/globals.css +28 -28
  32. package/app/layout.tsx +8 -5
  33. package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
  34. package/app/newsroom/[section]/page.tsx +8 -1
  35. package/app/newsroom/articles/page.tsx +10 -0
  36. package/app/newsroom/insights/[threadId]/page.tsx +5 -16
  37. package/app/newsroom/insights/page.tsx +4 -12
  38. package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
  39. package/app/page.tsx +30 -1
  40. package/components/administration-delegations-panel.tsx +156 -0
  41. package/components/curation-delegation-client.ts +104 -0
  42. package/components/item-stream-view.tsx +63 -0
  43. package/components/markus-ir-preview.tsx +47 -2
  44. package/components/news-desk-client-provider.tsx +17 -2
  45. package/components/news-desk-page.tsx +0 -1
  46. package/components/news-desk-taxonomy-client.ts +154 -240
  47. package/components/newspaper.tsx +4 -4
  48. package/components/newsroom-article-editor.tsx +138 -40
  49. package/components/newsroom-assignment-detail.tsx +79 -0
  50. package/components/newsroom-assignments-view.tsx +1 -1
  51. package/components/newsroom-board-view.tsx +153 -43
  52. package/components/newsroom-client-shell.tsx +8 -1
  53. package/components/newsroom-newsprint-board.css +5 -46
  54. package/components/newsroom-ops-overview.tsx +27 -5
  55. package/components/newsroom-organizer-shell.tsx +36 -13
  56. package/components/newsroom-organizer.css +269 -13
  57. package/components/newsroom-references-view.tsx +118 -23
  58. package/components/newsroom-topics-view.tsx +1 -1
  59. package/components/priming-proof-page.tsx +17 -8
  60. package/components/prospectus-account-chooser.tsx +131 -0
  61. package/components/reference-cyclotron-priming.tsx +85 -0
  62. package/components/reference-cyclotron-status.tsx +5 -5
  63. package/components/reference-cyclotron-suggestion.tsx +143 -0
  64. package/components/reference-live-agreement.tsx +62 -0
  65. package/components/reference-section-suggestion.tsx +104 -0
  66. package/components/story-reporting-trail-client.ts +91 -0
  67. package/components/story-reporting-trail.tsx +115 -0
  68. package/components/topic-steering-workspace.tsx +280 -679
  69. package/components/ui/sheet.tsx +10 -4
  70. package/components/use-paged-content-item-versions.ts +151 -0
  71. package/components/wiki-concepts-view.tsx +153 -0
  72. package/components/wiki-index-client.ts +51 -0
  73. package/infra/amplify-app-shell.js +17 -3
  74. package/infra/amplify-app-shell.ts +18 -3
  75. package/infra/site-config.js +6 -1
  76. package/infra/site-config.ts +7 -1
  77. package/lib/approved-priming-proof-reports.ts +6 -3
  78. package/lib/approved-priming-proofs/papyrus.json +90 -0
  79. package/lib/article-flow.ts +95 -0
  80. package/lib/article-frame-solver.ts +519 -0
  81. package/lib/blog-feature-solver.ts +1 -109
  82. package/lib/category-repository.ts +1 -1
  83. package/lib/chrome-text.ts +504 -0
  84. package/lib/content-actions-client.ts +3 -0
  85. package/lib/content-actions-demo.ts +55 -0
  86. package/lib/content-item-lifecycle.ts +34 -5
  87. package/lib/content-source-context.ts +9 -1
  88. package/lib/curation-delegation-helpers.ts +69 -0
  89. package/lib/cyclotron-suggestions.ts +263 -0
  90. package/lib/front-matter-fields.ts +42 -0
  91. package/lib/front-mosaic.ts +1133 -0
  92. package/lib/furniture.ts +385 -0
  93. package/lib/graphql-content-repository.ts +42 -7
  94. package/lib/item-stream.ts +47 -0
  95. package/lib/labels.ts +37 -0
  96. package/lib/layout-config.ts +237 -0
  97. package/lib/layout-plan.ts +40 -11
  98. package/lib/layout-scenarios.ts +21 -11
  99. package/lib/layout-types.ts +457 -0
  100. package/lib/layout-warnings.ts +8 -0
  101. package/lib/local-preview-section-fixtures.ts +72 -0
  102. package/lib/local-preview-seed.ts +286 -0
  103. package/lib/local-preview-tenant.ts +27 -0
  104. package/lib/local-preview.ts +3 -0
  105. package/lib/newspaper-layout.ts +17 -3410
  106. package/lib/newsroom-articles.ts +2 -0
  107. package/lib/newsroom-assignments.ts +14 -1
  108. package/lib/newsroom-board.ts +99 -47
  109. package/lib/newsroom-demo-dashboard.ts +1 -1
  110. package/lib/newsroom-forum-routes.ts +2 -43
  111. package/lib/newsroom-index-filters.ts +3 -40
  112. package/lib/newsroom-media-backend.ts +5 -0
  113. package/lib/newsroom-nav.ts +1 -3
  114. package/lib/newsroom-organizer-nav.ts +9 -16
  115. package/lib/newsroom-section-routing.ts +1 -1
  116. package/lib/newsroom-sections.ts +12 -0
  117. package/lib/papyrus-web-locations.ts +3 -24
  118. package/lib/pretext-layout.ts +22 -0
  119. package/lib/priming-proof-report.ts +1 -0
  120. package/lib/prospectus-account-client.ts +90 -0
  121. package/lib/prospectus-sign-in-routes.ts +45 -0
  122. package/lib/prospectus-site-identity.ts +13 -1
  123. package/lib/prospectus-tenant.ts +22 -0
  124. package/lib/reader-static-params.ts +2 -1
  125. package/lib/relevance-decisions.ts +29 -0
  126. package/lib/relevance-status.ts +73 -0
  127. package/lib/reporting-story-budget.ts +2 -2
  128. package/lib/semantic-graph.ts +5 -5
  129. package/lib/site-brand.ts +28 -1
  130. package/lib/site-env.ts +5 -0
  131. package/lib/site-section-suggestions.ts +120 -0
  132. package/lib/site-stack.ts +1 -1
  133. package/lib/story-reporting-trail.ts +106 -0
  134. package/lib/tenant-keys.ts +32 -0
  135. package/lib/text-columns.ts +251 -0
  136. package/lib/wiki.ts +56 -0
  137. package/package.json +46 -3
  138. package/renderers/pretext/presentation-shell.tsx +1 -1
  139. package/renderers/pretext/rhythm-article-page.tsx +1 -18
  140. package/routes.manifest.json +34 -15
  141. package/app/newsroom/work/page.tsx +0 -10
@@ -0,0 +1,385 @@
1
+ import {
2
+ type ArticleFrameChromeSpec,
3
+ type ChromeTextSlotSpec,
4
+ type LayoutMediaSpec,
5
+ type LayoutPullQuoteSpec,
6
+ type ResponsivePlacementAnchor,
7
+ type ResponsivePlacementSpec,
8
+ type ResponsiveSpanPolicy,
9
+ type ResponsiveVerticalPlacement,
10
+ } from "./layout-plan";
11
+ import { type ArticlePublicationItem, getPublicationItemImageAssets } from "./publication-items";
12
+ import {
13
+ reserveRhythmRows,
14
+ snapDownToRhythm,
15
+ snapPreferredHeightToRhythm,
16
+ snapPreservedImageHeightToRhythm,
17
+ snapToNearestRhythm,
18
+ type VerticalRhythm,
19
+ } from "./blog-rhythm";
20
+ import { type ArticleImageAsset } from "./articles";
21
+ import {
22
+ type LayoutConfig,
23
+ type MediaVariant,
24
+ type PlacementVariant,
25
+ type PullQuoteVariant,
26
+ type SolvedImageFurniture,
27
+ type SolvedPullQuoteFurniture,
28
+ } from "./layout-types";
29
+ import {
30
+ FURNITURE_COLLISION_GUTTER,
31
+ IMAGE_CAPTION_FONT_SIZE,
32
+ IMAGE_CAPTION_HORIZONTAL_PADDING_EM,
33
+ PULL_QUOTE_VERTICAL_PADDING,
34
+ SERIF_TEXT_FONT,
35
+ clamp,
36
+ getSpanRect,
37
+ } from "./layout-config";
38
+ import { buildChromeFont, emToPx, measureWrappedTextBlock, parseEmOverride } from "./chrome-text";
39
+
40
+ export function getMediaVariants(media: LayoutMediaSpec | undefined, config: LayoutConfig): MediaVariant[] {
41
+ if (!media) return [getNoMediaVariant(900)];
42
+ const variants = resolvePlacementVariants(media.placement, config).map((variant) => ({
43
+ ...variant,
44
+ assetRole: media.assetRole,
45
+ fallbackPenalty: variant.fallbackPenalty + Math.abs(variant.columnSpan - media.placement.span.preferred) * 900,
46
+ }));
47
+ if (media.required) return variants;
48
+ return [...variants, getNoMediaVariant(24_000)];
49
+ }
50
+
51
+ export function getNoMediaVariant(fallbackPenalty: number): MediaVariant {
52
+ return { id: "none", placement: null, columnStart: 0, columnSpan: 0, fallbackPenalty };
53
+ }
54
+
55
+ export function getPullQuoteVariants(
56
+ article: ArticlePublicationItem,
57
+ pullQuote: LayoutPullQuoteSpec | undefined,
58
+ config: LayoutConfig,
59
+ ): PullQuoteVariant[] {
60
+ const none = { id: "none", placement: null, columnStart: 0, columnSpan: 0, fallbackPenalty: 0 };
61
+ if (!pullQuote || !article.pullQuotes?.[0]) return [none];
62
+ const variants = pullQuote.placements.flatMap((placement, placementIndex) =>
63
+ resolvePlacementVariants(placement, config).map((variant) => ({
64
+ ...variant,
65
+ fallbackPenalty: variant.fallbackPenalty + placementIndex * 100,
66
+ })),
67
+ );
68
+ return pullQuote.required ? variants : [none, ...variants];
69
+ }
70
+
71
+ function resolvePlacementVariants(placement: ResponsivePlacementSpec, config: LayoutConfig): PlacementVariant[] {
72
+ const effectiveSpan = getEffectivePlacementSpanPolicy(placement, config.columnCount);
73
+ const collapsed = config.columnCount < effectiveSpan.min;
74
+ if (collapsed && placement.collapse === "omit") return [];
75
+ const spans = collapsed
76
+ ? [placement.collapse === "fullWidth" ? config.columnCount : 1]
77
+ : getSpanCandidates(effectiveSpan, config.columnCount);
78
+ return spans.flatMap((span, index) => {
79
+ const anchor = collapsed && placement.collapse === "inline" ? "inline" : placement.anchor ?? "left";
80
+ const columnStart = getColumnStartForPlacement(placement, resolveAnchor(anchor), span, config.columnCount, collapsed);
81
+ if (columnStart === null) return [];
82
+ return [{
83
+ id: `${resolveAnchor(anchor)}-span${span}-${placement.vertical}`,
84
+ placement,
85
+ columnStart,
86
+ columnSpan: span,
87
+ fallbackPenalty: index * 120 + (collapsed ? 320 : 0),
88
+ }];
89
+ });
90
+ }
91
+
92
+ function getEffectivePlacementSpanPolicy(
93
+ placement: ResponsivePlacementSpec,
94
+ columnCount: number,
95
+ ): ResponsiveSpanPolicy {
96
+ const override = placement.spanOverrides?.[String(columnCount)];
97
+ if (!override) return placement.span;
98
+ const clamped = clamp(override, placement.span.min, placement.span.max);
99
+ return { min: clamped, preferred: clamped, max: clamped };
100
+ }
101
+
102
+ export function resolvePreferredPlacementVariant(placement: ResponsivePlacementSpec, config: LayoutConfig): PlacementVariant | null {
103
+ return resolvePlacementVariants(placement, config)[0] ?? null;
104
+ }
105
+
106
+ export function createImageFurniture(
107
+ article: ArticlePublicationItem,
108
+ variant: MediaVariant,
109
+ config: LayoutConfig,
110
+ textHeight: number,
111
+ usedImageAssetIds: ReadonlySet<string> = new Set(),
112
+ ): SolvedImageFurniture | null {
113
+ if (!variant.placement || variant.columnSpan === 0) return null;
114
+ const asset = getPreferredImage(article, variant.placement, variant.assetRole, usedImageAssetIds);
115
+ if (!asset) return null;
116
+ const { x, width } = getSpanRect(config, variant.columnStart, variant.columnSpan);
117
+ const aspectRatio = getImageAspectRatio(asset);
118
+ const preferredHeight = Math.round(width / aspectRatio);
119
+ const canCropFrame = variant.placement.crop === "cropAllowed";
120
+ const shouldCropToFill = canCropFrame || asset.layout?.crop === "cover";
121
+ const minHeight = canCropFrame ? asset.layout?.minHeight ?? 140 : config.rhythm.rowHeight;
122
+ const maxHeight = canCropFrame ? asset.layout?.maxHeight ?? 420 : Number.POSITIVE_INFINITY;
123
+ const rawHeight = canCropFrame ? clamp(preferredHeight, minHeight, maxHeight) : preferredHeight;
124
+ const imageHeight = canCropFrame
125
+ ? snapPreferredHeightToRhythm(rawHeight, config.rhythm, minHeight, maxHeight)
126
+ : snapPreservedImageHeightToRhythm(rawHeight, config.rhythm, minHeight, maxHeight);
127
+ const caption = getImageCaption(asset);
128
+ const captionHeight = getImageCaptionHeight(caption, width, config.rhythm);
129
+ const height = imageHeight + captionHeight;
130
+ const y = getFurnitureY(variant.placement.vertical, height, textHeight, config);
131
+ const focalPoint = asset.layout?.focalPoint ?? { x: 0.5, y: 0.5 };
132
+ return {
133
+ kind: "image",
134
+ id: `${article.slug}-${variant.id}-image`,
135
+ assetId: asset.id,
136
+ src: asset.src,
137
+ themeVariants: asset.themeVariants,
138
+ alt: asset.alt,
139
+ caption,
140
+ credit: asset.credit,
141
+ templateId: `image-${variant.id}`,
142
+ columnStart: variant.columnStart,
143
+ columnSpan: variant.columnSpan,
144
+ x,
145
+ y,
146
+ width,
147
+ height,
148
+ imageHeight,
149
+ captionHeight,
150
+ captionFontSize: IMAGE_CAPTION_FONT_SIZE,
151
+ captionLineHeight: config.rhythm.rowHeight,
152
+ aspectRatio,
153
+ objectFit: shouldCropToFill ? "cover" : "contain",
154
+ objectPosition: `${Math.round(focalPoint.x * 100)}% ${Math.round(focalPoint.y * 100)}%`,
155
+ wrapsText: variant.placement.wrapsText,
156
+ preferredHeight: preferredHeight + captionHeight,
157
+ };
158
+ }
159
+
160
+ export function createPullQuoteFurniture(
161
+ article: ArticlePublicationItem,
162
+ variant: PullQuoteVariant,
163
+ config: LayoutConfig,
164
+ textHeight: number,
165
+ image: SolvedImageFurniture | null,
166
+ chrome?: ArticleFrameChromeSpec,
167
+ ): SolvedPullQuoteFurniture | null {
168
+ if (!variant.placement || variant.columnSpan === 0) return null;
169
+ const text = article.pullQuotes?.[0];
170
+ if (!text) return null;
171
+ const { x, width } = getSpanRect(config, variant.columnStart, variant.columnSpan);
172
+ const metrics = getPullQuoteMetrics(text, width, config, chrome?.pullQuote);
173
+ const y = getPullQuoteY({
174
+ x,
175
+ width,
176
+ height: metrics.height,
177
+ textHeight,
178
+ anchorRatio: getVerticalAnchorRatio(variant.placement.vertical),
179
+ image,
180
+ config,
181
+ });
182
+ if (y === null) return null;
183
+ return {
184
+ kind: "pullQuote",
185
+ id: `${article.slug}-${variant.id}-pullquote`,
186
+ text,
187
+ templateId: `pullquote-${variant.id}`,
188
+ columnStart: variant.columnStart,
189
+ columnSpan: variant.columnSpan,
190
+ x,
191
+ y,
192
+ width,
193
+ height: metrics.height,
194
+ fontSize: metrics.fontSize,
195
+ lineHeight: metrics.lineHeight,
196
+ wrapsText: true,
197
+ };
198
+ }
199
+
200
+ export function getPreferredImage(
201
+ article: ArticlePublicationItem,
202
+ _placement: ResponsivePlacementSpec,
203
+ assetRole: string | undefined,
204
+ usedImageAssetIds: ReadonlySet<string> = new Set(),
205
+ ): ArticleImageAsset | null {
206
+ const assets = getPublicationItemImageAssets(article).filter((asset) => !usedImageAssetIds.has(asset.id));
207
+ if (assetRole) {
208
+ const roleAsset = assets.find((asset) => asset.roles?.some((role) => role === assetRole));
209
+ if (roleAsset) return roleAsset;
210
+ }
211
+ return (
212
+ assets.find((asset) => asset.roles?.some((role) => role === "continuationInset")) ??
213
+ assets.find((asset) => asset.roles?.some((role) => role === "continuation")) ??
214
+ assets[0] ??
215
+ null
216
+ );
217
+ }
218
+
219
+ export function hasAvailablePreferredImage(
220
+ article: ArticlePublicationItem,
221
+ placement: ResponsivePlacementSpec,
222
+ assetRole: string | undefined,
223
+ usedImageAssetIds: ReadonlySet<string>,
224
+ ): boolean {
225
+ return getPreferredImage(article, placement, assetRole, usedImageAssetIds) !== null;
226
+ }
227
+
228
+ function getSpanCandidates(span: ResponsiveSpanPolicy, columnCount: number): number[] {
229
+ const min = Math.min(span.min, columnCount);
230
+ const max = Math.min(span.max, columnCount);
231
+ const preferred = clamp(span.preferred, min, max);
232
+ const candidates = [preferred, min, max];
233
+ return Array.from(new Set(candidates)).sort((left, right) => (
234
+ Math.abs(left - preferred) - Math.abs(right - preferred) || right - left
235
+ ));
236
+ }
237
+
238
+ function resolveAnchor(anchor: ResponsivePlacementAnchor): "left" | "right" | "center" | "inline" {
239
+ if (anchor === "outer") return "right";
240
+ if (anchor === "inner") return "left";
241
+ if (anchor === "inline") return "left";
242
+ return anchor;
243
+ }
244
+
245
+ function getColumnStart(anchor: "left" | "right" | "center" | "inline", span: number, columnCount: number): number {
246
+ if (anchor === "right") return Math.max(0, columnCount - span);
247
+ if (anchor === "center") return Math.max(0, Math.floor((columnCount - span) / 2));
248
+ return 0;
249
+ }
250
+
251
+ function getColumnStartForPlacement(
252
+ placement: ResponsivePlacementSpec,
253
+ anchor: "left" | "right" | "center" | "inline",
254
+ span: number,
255
+ columnCount: number,
256
+ collapsed: boolean,
257
+ ): number | null {
258
+ if (!placement.columnStart || collapsed) return getColumnStart(anchor, span, columnCount);
259
+ const requested = placement.columnStart - 1;
260
+ if (requested + span <= columnCount) return requested;
261
+ if (placement.collapse === "omit") return null;
262
+ if (placement.collapse === "fullWidth") return 0;
263
+ return getColumnStart("inline", Math.min(span, columnCount), columnCount);
264
+ }
265
+
266
+ function getFurnitureY(
267
+ verticalAnchor: ResponsiveVerticalPlacement,
268
+ height: number,
269
+ textHeight: number,
270
+ config: LayoutConfig,
271
+ ): number {
272
+ if (verticalAnchor === "top") return 0;
273
+ const minY = config.lineHeight * 2;
274
+ const maxY = Math.max(0, textHeight - height);
275
+ const ratio = getVerticalAnchorRatio(verticalAnchor);
276
+ const minimumY = Math.min(reserveRhythmRows(minY, config.rhythm), maxY);
277
+ const maximumY = snapDownToRhythm(maxY, config.rhythm);
278
+ if (maximumY < minimumY) return maximumY;
279
+ return clamp(snapToNearestRhythm(textHeight * ratio - height / 2, config.rhythm), minimumY, maximumY);
280
+ }
281
+
282
+ function getVerticalAnchorRatio(verticalAnchor: ResponsiveVerticalPlacement): number {
283
+ if (verticalAnchor === "top") return 0.12;
284
+ if (verticalAnchor === "upperThird") return 0.32;
285
+ if (verticalAnchor === "lowerThird") return 0.66;
286
+ return 0.5;
287
+ }
288
+
289
+ function getPullQuoteMetrics(
290
+ text: string,
291
+ width: number,
292
+ config: LayoutConfig,
293
+ overrides?: ChromeTextSlotSpec,
294
+ ): { height: number; fontSize: number; lineHeight: number } {
295
+ const narrow = config.columnCount === 1;
296
+ const fontSize = narrow ? 20 : 22;
297
+ const lineHeight = Math.ceil(emToPx(parseEmOverride(overrides?.lineHeight, 1.12), fontSize));
298
+ const paintHeight = Math.ceil(emToPx(parseEmOverride(overrides?.paintHeight, 1.18), fontSize));
299
+ const metrics = measureWrappedTextBlock(text, buildChromeFont({
300
+ fontSize,
301
+ fontFamily: SERIF_TEXT_FONT,
302
+ fontStyle: "italic",
303
+ lineHeightEm: lineHeight / fontSize,
304
+ paintHeightEm: paintHeight / fontSize,
305
+ }), width, lineHeight, paintHeight);
306
+ const marginBefore = Math.round(emToPx(parseEmOverride(overrides?.marginBefore, PULL_QUOTE_VERTICAL_PADDING / 2 / fontSize), fontSize));
307
+ const marginAfter = Math.round(emToPx(parseEmOverride(overrides?.marginAfter, PULL_QUOTE_VERTICAL_PADDING / 2 / fontSize), fontSize));
308
+ const minHeight = Math.ceil(emToPx(parseEmOverride(overrides?.minHeight, 0), fontSize));
309
+ const height = reserveRhythmRows(Math.max(metrics.height + marginBefore + marginAfter, minHeight), config.rhythm);
310
+ return { height, fontSize, lineHeight };
311
+ }
312
+
313
+ function getPullQuoteY({
314
+ x,
315
+ width,
316
+ height,
317
+ textHeight,
318
+ anchorRatio,
319
+ image,
320
+ config,
321
+ }: {
322
+ x: number;
323
+ width: number;
324
+ height: number;
325
+ textHeight: number;
326
+ anchorRatio: number;
327
+ image: SolvedImageFurniture | null;
328
+ config: LayoutConfig;
329
+ }): number | null {
330
+ const minY = config.lineHeight * 2;
331
+ const maxY = textHeight - height;
332
+ if (maxY < minY) return null;
333
+ const minimumY = reserveRhythmRows(minY, config.rhythm);
334
+ const maximumY = snapDownToRhythm(maxY, config.rhythm);
335
+ if (maximumY < minimumY) return null;
336
+ const preferredY = clamp(snapToNearestRhythm(textHeight * anchorRatio - height / 2, config.rhythm), minimumY, maximumY);
337
+ const preferredRect = { x, y: preferredY, width, height };
338
+ if (!image || !rectsOverlap(preferredRect, image, FURNITURE_COLLISION_GUTTER)) return preferredY;
339
+ const candidateYs = [
340
+ reserveRhythmRows(image.y + image.height + FURNITURE_COLLISION_GUTTER, config.rhythm),
341
+ snapDownToRhythm(image.y - height - FURNITURE_COLLISION_GUTTER, config.rhythm),
342
+ ]
343
+ .filter((candidateY) => candidateY >= minimumY && candidateY <= maximumY)
344
+ .sort((leftY, rightY) => Math.abs(leftY - preferredY) - Math.abs(rightY - preferredY));
345
+ return candidateYs.find((candidateY) => !rectsOverlap({ x, y: candidateY, width, height }, image, FURNITURE_COLLISION_GUTTER)) ?? null;
346
+ }
347
+
348
+ export function rectsOverlap(
349
+ a: { x: number; y: number; width: number; height: number },
350
+ b: { x: number; y: number; width: number; height: number },
351
+ gutter = 0,
352
+ ): boolean {
353
+ return (
354
+ a.x < b.x + b.width + gutter &&
355
+ a.x + a.width + gutter > b.x &&
356
+ a.y < b.y + b.height + gutter &&
357
+ a.y + a.height + gutter > b.y
358
+ );
359
+ }
360
+
361
+ export function getImageAspectRatio(asset: Pick<ArticleImageAsset, "layout">): number {
362
+ return asset.layout?.aspectRatio ?? 1.5;
363
+ }
364
+
365
+ export function getImageCaption(asset: Pick<ArticleImageAsset, "caption" | "credit">): string {
366
+ return (asset.caption ?? asset.credit).trim();
367
+ }
368
+
369
+ export function getImageCaptionHeight(caption: string, width: number, rhythm: VerticalRhythm): number {
370
+ if (!caption) return 0;
371
+ const contentWidth = Math.max(1, width - IMAGE_CAPTION_FONT_SIZE * IMAGE_CAPTION_HORIZONTAL_PADDING_EM);
372
+ const metrics = measureWrappedTextBlock(
373
+ caption,
374
+ `italic ${IMAGE_CAPTION_FONT_SIZE}px ${SERIF_TEXT_FONT}`,
375
+ contentWidth,
376
+ rhythm.rowHeight,
377
+ rhythm.rowHeight,
378
+ );
379
+ return reserveRhythmRows(Math.max(rhythm.rowHeight, metrics.height), rhythm);
380
+ }
381
+
382
+ export function getObjectPosition(asset: Pick<ArticleImageAsset, "layout">): string {
383
+ const focalPoint = asset.layout?.focalPoint ?? { x: 0.5, y: 0.5 };
384
+ return `${Math.round(focalPoint.x * 100)}% ${Math.round(focalPoint.y * 100)}%`;
385
+ }
@@ -20,7 +20,9 @@ import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type
20
20
  import { SITE_BRAND } from "./site-brand";
21
21
  import { shouldFetchVideoScripts } from "./video-mode";
22
22
  import { collectVideoScriptTargets, parseVideoScriptRef, videomlItemSlug, type VideoScriptRef } from "./video-script";
23
- import { currentContentSource, currentRequestClient, runWithContentSource } from "./content-source-context";
23
+ import { selectedTenantIdFromCookies } from "./prospectus-tenant";
24
+ import { currentContentSource, currentRequestClient, currentTenantId, runWithContentSource } from "./content-source-context";
25
+ import { rowBelongsToTenant, tenantScopedValue } from "./tenant-keys";
24
26
  import { withRateLimitRetry } from "./rate-limit-retry";
25
27
  import { getContentSource } from "./site-env";
26
28
 
@@ -70,6 +72,7 @@ type GraphQLEditionItem = {
70
72
 
71
73
  type GraphQLItem = {
72
74
  id: string;
75
+ tenantId?: string | null;
73
76
  versionNumber?: number | null;
74
77
  type: string;
75
78
  status: string;
@@ -152,9 +155,10 @@ async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise
152
155
  // Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
153
156
  // kept in the request's async context (never a module global) so one visitor's session cannot serve another.
154
157
  const { cookies } = await import("next/headers");
155
- await cookies();
158
+ const cookieStore = await cookies();
156
159
  const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
157
- return runWithContentSource(source, operation, client);
160
+ const selectedTenantId = await selectedTenantIdFromCookies(cookieStore);
161
+ return runWithContentSource(source, operation, client, selectedTenantId);
158
162
  }
159
163
  return withRateLimitRetry(() =>
160
164
  runWithGuestAmplifyContext(() =>
@@ -587,6 +591,10 @@ type ItemSourceModel = {
587
591
  listPublishedItemsByTypeStatusAndPublishedAt: (input: { typeStatus: string }, options: ListOptions) => ListResponse<GraphQLItem>;
588
592
  };
589
593
 
594
+ type TenantSlugSourceModel = {
595
+ publishedItemByTenantSlug: (input: { tenantSlug: string }, options: ListOptions) => ListResponse<GraphQLItem>;
596
+ };
597
+
590
598
  type MediaAssetSourceModel = {
591
599
  listPublishedMediaAssetsByItemAndSortKey: (
592
600
  input: { publishedItemId: string },
@@ -671,20 +679,33 @@ function getPublishedEditionItemModel(): EditionItemSourceModel {
671
679
  return model as unknown as EditionItemSourceModel;
672
680
  }
673
681
 
682
+ async function getItemWithinTenant(
683
+ tenantId: string | undefined,
684
+ lookup: Promise<GraphQLGetResponse<GraphQLItem>>,
685
+ ): Promise<GraphQLGetResponse<GraphQLItem>> {
686
+ const response = await lookup;
687
+ return rowBelongsToTenant(tenantId, response.data) ? response : { ...response, data: null };
688
+ }
689
+
674
690
  function getPublishedItemModel(): ItemSourceModel {
691
+ const tenantId = currentTenantId();
675
692
  if (currentContentSource() === "drafts") {
676
693
  const model = getClient().models.Item;
677
694
  if (!model) throw missingProjectionModelError("Item");
678
695
  return {
679
- get: (input, options) => model.get(input, options) as Promise<GraphQLGetResponse<GraphQLItem>>,
680
- publishedItemBySlug: (input, options) => model.itemBySlug(input, options) as ListResponse<GraphQLItem>,
696
+ get: (input, options) =>
697
+ getItemWithinTenant(tenantId, model.get(input, options) as Promise<GraphQLGetResponse<GraphQLItem>>),
698
+ publishedItemBySlug: ({ slug }, options) =>
699
+ (tenantId
700
+ ? model.itemByTenantSlug({ tenantSlug: tenantScopedValue(tenantId, slug) }, options)
701
+ : model.itemBySlug({ slug }, options)) as ListResponse<GraphQLItem>,
681
702
  listPublishedItemsByTypeStatusAndPublishedAt: ({ typeStatus }, options) => {
682
703
  const itemType = typeStatus.split("#")[0];
683
704
  return listMergedAcrossStatuses<GraphQLItem>(
684
705
  [DRAFT_STATUS, PUBLISHED_STATUS],
685
706
  (status, pageOptions) =>
686
707
  model.listItemsByTypeStatusAndPublishedAt(
687
- { typeStatus: `${itemType}#${status}` },
708
+ { typeStatus: tenantScopedValue(tenantId, `${itemType}#${status}`) },
688
709
  pageOptions,
689
710
  ) as ListResponse<GraphQLItem>,
690
711
  (item) => item.publishedAt ?? "",
@@ -695,7 +716,21 @@ function getPublishedItemModel(): ItemSourceModel {
695
716
  }
696
717
  const model = getClient().models.PublishedItem;
697
718
  if (!model) throw missingProjectionModelError("PublishedItem");
698
- return model as unknown as ItemSourceModel;
719
+ const publishedModel = model as unknown as ItemSourceModel;
720
+ if (!tenantId) return publishedModel;
721
+ return {
722
+ get: (input, options) => getItemWithinTenant(tenantId, publishedModel.get(input, options)),
723
+ publishedItemBySlug: ({ slug }, options) =>
724
+ (model as unknown as TenantSlugSourceModel).publishedItemByTenantSlug(
725
+ { tenantSlug: tenantScopedValue(tenantId, slug) },
726
+ options,
727
+ ),
728
+ listPublishedItemsByTypeStatusAndPublishedAt: ({ typeStatus }, options) =>
729
+ publishedModel.listPublishedItemsByTypeStatusAndPublishedAt(
730
+ { typeStatus: tenantScopedValue(tenantId, typeStatus) },
731
+ options,
732
+ ),
733
+ };
699
734
  }
700
735
 
701
736
  function getPublishedMediaAssetModel(): MediaAssetSourceModel {
@@ -0,0 +1,47 @@
1
+ import type { EditionContent, EditionSection } from "./content-types";
2
+ import { createEditionSectionPlan, createSectionKey } from "./edition-sections";
3
+ import { getArticlePublicationItems, type ArticlePublicationItem } from "./publication-items";
4
+
5
+ export const ITEM_STREAM_RECENT_EDITION_COUNT = 10;
6
+ export const ITEM_STREAM_ITEM_LIMIT = 50;
7
+
8
+ export type ItemStream = {
9
+ items: ArticlePublicationItem[];
10
+ sections: EditionSection[];
11
+ activeSectionKey: string | null;
12
+ };
13
+
14
+ /**
15
+ * Flattens published content into one newest-first stream of articles. The
16
+ * input is ordered newest first; a slug that appears twice keeps its newest entry.
17
+ */
18
+ export function selectNewestStreamItems(
19
+ newestFirstContents: EditionContent[],
20
+ limit: number = ITEM_STREAM_ITEM_LIMIT,
21
+ ): ArticlePublicationItem[] {
22
+ const seenSlugs = new Set<string>();
23
+ const selected: ArticlePublicationItem[] = [];
24
+ for (const content of newestFirstContents) {
25
+ for (const item of getArticlePublicationItems(content.items)) {
26
+ if (seenSlugs.has(item.slug)) continue;
27
+ seenSlugs.add(item.slug);
28
+ selected.push(item);
29
+ if (selected.length >= limit) return selected;
30
+ }
31
+ }
32
+ return selected;
33
+ }
34
+
35
+ /** Builds the stream view: the sections found in the items, and the items filtered by an optional section key. */
36
+ export function buildItemStream(items: ArticlePublicationItem[], sectionKey?: string | null): ItemStream {
37
+ const sections = createEditionSectionPlan(items);
38
+ const activeSectionKey = sectionKey ? createSectionKey(sectionKey) : null;
39
+ const activeSection = activeSectionKey ? sections.find((section) => section.key === activeSectionKey) : undefined;
40
+ if (!activeSection) return { items, sections, activeSectionKey: null };
41
+ const itemIds = new Set(activeSection.itemIds);
42
+ return {
43
+ items: items.filter((item) => itemIds.has(item.slug)),
44
+ sections,
45
+ activeSectionKey: activeSection.key,
46
+ };
47
+ }
package/lib/labels.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { type ArticlePublicationItem } from "./publication-items";
2
+ import { type ArticleFrameBlockSpec } from "./layout-plan";
3
+ import { type ArticleFlow } from "./layout-types";
4
+
5
+ export function formatContinuationJumpLabel(article: ArticlePublicationItem, pageNumber: number): string {
6
+ return `SEE ${getShortSlug(article) ?? "MORE"} ON ${formatSectionPage(pageNumber)}`;
7
+ }
8
+
9
+ export function getArticleFrameLabel(blockSpec: ArticleFrameBlockSpec, article: ArticlePublicationItem, flow: ArticleFlow): string {
10
+ if (blockSpec.startCursor !== "current") return article.section;
11
+ const previousRange = flow.placedRanges[flow.placedRanges.length - 1];
12
+ if (!previousRange) throw new Error(`Continuation block ${blockSpec.id} has no earlier text range to continue from`);
13
+ return formatContinuationSourceLabel(article, getPageNumberFromPageId(previousRange.pageId));
14
+ }
15
+
16
+ function getPageNumberFromPageId(pageId: string): number {
17
+ return Number(pageId.replace(/^page-/, ""));
18
+ }
19
+
20
+ function formatContinuationSourceLabel(article: ArticlePublicationItem, sourcePageNumber: number): string {
21
+ const shortSlug = getShortSlug(article);
22
+ const source = `FROM ${formatSectionPage(sourcePageNumber)}`;
23
+ return shortSlug ? `${shortSlug} ${source}` : source;
24
+ }
25
+
26
+ function getShortSlug(article: ArticlePublicationItem): string | null {
27
+ const shortSlug = article.shortSlug?.trim().toUpperCase();
28
+ return shortSlug || null;
29
+ }
30
+
31
+ function formatSectionPage(pageNumber: number): string {
32
+ return `A${pageNumber}`;
33
+ }
34
+
35
+ export function formatByline(article: ArticlePublicationItem): string {
36
+ return `${article.byline} / ${article.dateline}`.toUpperCase();
37
+ }