@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.
- package/amplify/data/resource.js +134 -3
- package/amplify/data/resource.ts +145 -3
- package/amplify/data/schema.js +131 -0
- package/amplify/data/schema.ts +142 -0
- package/amplify/functions/category-action/handler.ts +272 -20
- package/amplify/functions/console-chat-responder/src/main.rs +1 -1
- package/amplify/functions/content-actions/handler.py +23 -0
- package/amplify/functions/content-actions/requirements.txt +1 -1
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/renditions/requirements.txt +1 -1
- package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
- package/amplify/functions/shared/curation-delegation.ts +115 -0
- package/amplify/functions/shared/python-bundle.js +3 -3
- package/amplify/functions/shared/python-bundle.ts +3 -3
- package/amplify/functions/shared/reference-curation-metadata.ts +51 -0
- package/amplify/functions/shared/slack-events.ts +2 -2
- package/amplify/publication/renditions-function.js +1 -1
- package/amplify/publication/renditions-function.ts +1 -1
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/page.tsx +3 -0
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
- package/app/api/archive/editions/route.ts +2 -0
- package/app/api/auth/prospectus/accounts/route.ts +9 -0
- package/app/api/local-preview/derive/route.ts +28 -0
- package/app/api/local-preview/media/route.ts +25 -0
- package/app/archive/page.tsx +3 -0
- package/app/globals.css +28 -28
- package/app/layout.tsx +8 -5
- package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
- package/app/newsroom/[section]/page.tsx +8 -1
- package/app/newsroom/articles/page.tsx +10 -0
- package/app/newsroom/insights/[threadId]/page.tsx +5 -16
- package/app/newsroom/insights/page.tsx +4 -12
- package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
- package/app/page.tsx +30 -1
- package/components/administration-delegations-panel.tsx +156 -0
- package/components/curation-delegation-client.ts +104 -0
- package/components/item-stream-view.tsx +63 -0
- package/components/markus-ir-preview.tsx +47 -2
- package/components/news-desk-client-provider.tsx +17 -2
- package/components/news-desk-page.tsx +0 -1
- package/components/news-desk-taxonomy-client.ts +154 -240
- package/components/newspaper.tsx +4 -4
- package/components/newsroom-article-editor.tsx +138 -40
- package/components/newsroom-assignment-detail.tsx +79 -0
- package/components/newsroom-assignments-view.tsx +1 -1
- package/components/newsroom-board-view.tsx +153 -43
- package/components/newsroom-client-shell.tsx +8 -1
- package/components/newsroom-newsprint-board.css +5 -46
- package/components/newsroom-ops-overview.tsx +27 -5
- package/components/newsroom-organizer-shell.tsx +36 -13
- package/components/newsroom-organizer.css +269 -13
- package/components/newsroom-references-view.tsx +118 -23
- package/components/newsroom-topics-view.tsx +1 -1
- package/components/priming-proof-page.tsx +17 -8
- package/components/prospectus-account-chooser.tsx +131 -0
- package/components/reference-cyclotron-priming.tsx +85 -0
- package/components/reference-cyclotron-status.tsx +5 -5
- package/components/reference-cyclotron-suggestion.tsx +143 -0
- package/components/reference-live-agreement.tsx +62 -0
- package/components/reference-section-suggestion.tsx +104 -0
- package/components/story-reporting-trail-client.ts +91 -0
- package/components/story-reporting-trail.tsx +115 -0
- package/components/topic-steering-workspace.tsx +280 -679
- package/components/ui/sheet.tsx +10 -4
- package/components/use-paged-content-item-versions.ts +151 -0
- package/components/wiki-concepts-view.tsx +153 -0
- package/components/wiki-index-client.ts +51 -0
- package/infra/amplify-app-shell.js +17 -3
- package/infra/amplify-app-shell.ts +18 -3
- package/infra/site-config.js +6 -1
- package/infra/site-config.ts +7 -1
- package/lib/approved-priming-proof-reports.ts +6 -3
- package/lib/approved-priming-proofs/papyrus.json +90 -0
- package/lib/article-flow.ts +95 -0
- package/lib/article-frame-solver.ts +519 -0
- package/lib/blog-feature-solver.ts +1 -109
- package/lib/category-repository.ts +1 -1
- package/lib/chrome-text.ts +504 -0
- package/lib/content-actions-client.ts +3 -0
- package/lib/content-actions-demo.ts +55 -0
- package/lib/content-item-lifecycle.ts +34 -5
- package/lib/content-source-context.ts +9 -1
- package/lib/curation-delegation-helpers.ts +69 -0
- package/lib/cyclotron-suggestions.ts +263 -0
- package/lib/front-matter-fields.ts +42 -0
- package/lib/front-mosaic.ts +1133 -0
- package/lib/furniture.ts +385 -0
- package/lib/graphql-content-repository.ts +42 -7
- package/lib/item-stream.ts +47 -0
- package/lib/labels.ts +37 -0
- package/lib/layout-config.ts +237 -0
- package/lib/layout-plan.ts +40 -11
- package/lib/layout-scenarios.ts +21 -11
- package/lib/layout-types.ts +457 -0
- package/lib/layout-warnings.ts +8 -0
- package/lib/local-preview-section-fixtures.ts +72 -0
- package/lib/local-preview-seed.ts +286 -0
- package/lib/local-preview-tenant.ts +27 -0
- package/lib/local-preview.ts +3 -0
- package/lib/newspaper-layout.ts +17 -3410
- package/lib/newsroom-articles.ts +2 -0
- package/lib/newsroom-assignments.ts +14 -1
- package/lib/newsroom-board.ts +99 -47
- package/lib/newsroom-demo-dashboard.ts +1 -1
- package/lib/newsroom-forum-routes.ts +2 -43
- package/lib/newsroom-index-filters.ts +3 -40
- package/lib/newsroom-media-backend.ts +5 -0
- package/lib/newsroom-nav.ts +1 -3
- package/lib/newsroom-organizer-nav.ts +9 -16
- package/lib/newsroom-section-routing.ts +1 -1
- package/lib/newsroom-sections.ts +12 -0
- package/lib/papyrus-web-locations.ts +3 -24
- package/lib/pretext-layout.ts +22 -0
- package/lib/priming-proof-report.ts +1 -0
- package/lib/prospectus-account-client.ts +90 -0
- package/lib/prospectus-sign-in-routes.ts +45 -0
- package/lib/prospectus-site-identity.ts +13 -1
- package/lib/prospectus-tenant.ts +22 -0
- package/lib/reader-static-params.ts +2 -1
- package/lib/relevance-decisions.ts +29 -0
- package/lib/relevance-status.ts +73 -0
- package/lib/reporting-story-budget.ts +2 -2
- package/lib/semantic-graph.ts +5 -5
- package/lib/site-brand.ts +28 -1
- package/lib/site-env.ts +5 -0
- package/lib/site-section-suggestions.ts +120 -0
- package/lib/site-stack.ts +1 -1
- package/lib/story-reporting-trail.ts +106 -0
- package/lib/tenant-keys.ts +32 -0
- package/lib/text-columns.ts +251 -0
- package/lib/wiki.ts +56 -0
- package/package.json +46 -3
- package/renderers/pretext/presentation-shell.tsx +1 -1
- package/renderers/pretext/rhythm-article-page.tsx +1 -18
- package/routes.manifest.json +34 -15
- package/app/newsroom/work/page.tsx +0 -10
package/lib/furniture.ts
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
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) =>
|
|
680
|
-
|
|
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
|
-
|
|
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
|
+
}
|