@anthusai/papyrus 1.0.0-next.16 → 1.0.0-next.18

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.
@@ -3,6 +3,7 @@ const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
3
3
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
4
4
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
5
5
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
6
+ const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
6
7
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
7
8
  const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
8
9
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
@@ -11,7 +12,7 @@ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}
11
12
  const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
12
13
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
13
14
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
14
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH"];
15
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH"];
15
16
  const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
16
17
  function fail(field, problem) {
17
18
  throw new Error(`site.json: ${field} ${problem}`);
@@ -53,9 +54,17 @@ function rejectUnknownKeys(record, allowed, field) {
53
54
  export function isStagingEnabled(config) {
54
55
  return config.cms.staging !== false;
55
56
  }
57
+ export function resolveCmsHostName(config) {
58
+ if (!config.cms.domainName)
59
+ return undefined;
60
+ return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
61
+ }
56
62
  export function resolveStagingDomainName(config) {
57
63
  if (!isStagingEnabled(config))
58
64
  return undefined;
65
+ if (config.cms.domainPrefix) {
66
+ return config.cms.stagingDomainPrefix ? `${config.cms.stagingDomainPrefix}.${config.cms.domainName}` : undefined;
67
+ }
59
68
  if (config.cms.stagingDomainName)
60
69
  return config.cms.stagingDomainName;
61
70
  if (!config.cms.domainName)
@@ -84,7 +93,7 @@ export function parseSiteConfig(raw) {
84
93
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
85
94
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
86
95
  const cmsRecord = requireObject(record.cms, "cms");
87
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName"], "cms");
96
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix"], "cms");
88
97
  const buildComputeType = cmsRecord.buildComputeType;
89
98
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
90
99
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -92,14 +101,20 @@ export function parseSiteConfig(raw) {
92
101
  if (cmsRecord.staging !== undefined && typeof cmsRecord.staging !== "boolean") {
93
102
  fail("cms.staging", `must be a boolean, got ${JSON.stringify(cmsRecord.staging)}`);
94
103
  }
104
+ if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
105
+ fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
106
+ }
95
107
  const cms = {
96
108
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
97
109
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
110
+ domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
98
111
  staging: cmsRecord.staging,
99
112
  cognitoDomainPrefix: requireString(cmsRecord.cognitoDomainPrefix, "cms.cognitoDomainPrefix", COGNITO_DOMAIN_PREFIX_PATTERN, "lowercase letters, digits and hyphens; globally unique per region"),
113
+ applyCognitoDomainPrefix: cmsRecord.applyCognitoDomainPrefix,
100
114
  buildComputeType: buildComputeType,
101
115
  environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
102
116
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
117
+ stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
103
118
  };
104
119
  const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
105
120
  if (reservedWord) {
@@ -116,6 +131,24 @@ export function parseSiteConfig(raw) {
116
131
  if (cms.stagingDomainName && !cms.domainName) {
117
132
  fail("cms.stagingDomainName", "requires cms.domainName");
118
133
  }
134
+ if (cms.domainPrefix && !cms.domainName) {
135
+ fail("cms.domainPrefix", "requires cms.domainName (the root domain)");
136
+ }
137
+ if (cms.stagingDomainPrefix && !cms.domainPrefix) {
138
+ fail("cms.stagingDomainPrefix", "requires cms.domainPrefix");
139
+ }
140
+ if (cms.domainPrefix && cms.stagingDomainName) {
141
+ fail("cms.stagingDomainName", "must not be set with cms.domainPrefix: use cms.stagingDomainPrefix");
142
+ }
143
+ if (cms.staging === false && cms.stagingDomainPrefix) {
144
+ fail("cms.stagingDomainPrefix", "must not be set when cms.staging is false");
145
+ }
146
+ if (cms.domainPrefix && cms.staging !== false && !cms.stagingDomainPrefix) {
147
+ fail("cms.stagingDomainPrefix", "is required when cms.domainPrefix is set and staging is enabled: the staging host is never derived under a shared root domain");
148
+ }
149
+ if (cms.domainPrefix && cms.stagingDomainPrefix === cms.domainPrefix) {
150
+ fail("cms.stagingDomainPrefix", "must differ from cms.domainPrefix");
151
+ }
119
152
  let reader;
120
153
  if (record.reader !== undefined) {
121
154
  if (frontend !== "markus-static")
@@ -186,7 +219,7 @@ export function parseSiteConfig(raw) {
186
219
  const redirectUrls = redirectValue.split(",").map((url) => url.trim());
187
220
  const stagingDomainName = resolveStagingDomainName(config);
188
221
  const requiredOrigins = [
189
- ...(cms.domainName ? [`https://${cms.domainName}/`] : []),
222
+ ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}/`] : []),
190
223
  ...(stagingDomainName ? [`https://${stagingDomainName}/`] : []),
191
224
  LOCAL_DEVELOPMENT_ORIGIN,
192
225
  ];
@@ -8,11 +8,14 @@ export type AmplifyAppShellSiteConfig = {
8
8
  cms: {
9
9
  appName?: string;
10
10
  domainName?: string;
11
+ domainPrefix?: string;
11
12
  staging?: boolean;
12
13
  cognitoDomainPrefix: string;
14
+ applyCognitoDomainPrefix?: boolean;
13
15
  buildComputeType?: "STANDARD" | "STANDARD_8GB";
14
16
  environment: Record<string, string>;
15
17
  stagingDomainName?: string;
18
+ stagingDomainPrefix?: string;
16
19
  };
17
20
  reader?: {
18
21
  appName?: string;
@@ -38,6 +41,7 @@ const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
38
41
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
39
42
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
40
43
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
44
+ const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
41
45
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
42
46
  const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
43
47
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
@@ -46,7 +50,7 @@ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}
46
50
  const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
47
51
  const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
48
52
  const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
49
- const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH"];
53
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH"];
50
54
  const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
51
55
 
52
56
  function fail(field: string, problem: string): never {
@@ -94,8 +98,16 @@ export function isStagingEnabled(config: Pick<AmplifyAppShellSiteConfig, "cms">)
94
98
  return config.cms.staging !== false;
95
99
  }
96
100
 
101
+ export function resolveCmsHostName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string | undefined {
102
+ if (!config.cms.domainName) return undefined;
103
+ return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
104
+ }
105
+
97
106
  export function resolveStagingDomainName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string | undefined {
98
107
  if (!isStagingEnabled(config)) return undefined;
108
+ if (config.cms.domainPrefix) {
109
+ return config.cms.stagingDomainPrefix ? `${config.cms.stagingDomainPrefix}.${config.cms.domainName}` : undefined;
110
+ }
99
111
  if (config.cms.stagingDomainName) return config.cms.stagingDomainName;
100
112
  if (!config.cms.domainName) return undefined;
101
113
  const [, ...rest] = config.cms.domainName.split(".");
@@ -131,7 +143,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
131
143
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
132
144
 
133
145
  const cmsRecord = requireObject(record.cms, "cms");
134
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName"], "cms");
146
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix"], "cms");
135
147
  const buildComputeType = cmsRecord.buildComputeType;
136
148
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
137
149
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -139,9 +151,13 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
139
151
  if (cmsRecord.staging !== undefined && typeof cmsRecord.staging !== "boolean") {
140
152
  fail("cms.staging", `must be a boolean, got ${JSON.stringify(cmsRecord.staging)}`);
141
153
  }
154
+ if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
155
+ fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
156
+ }
142
157
  const cms: AmplifyAppShellSiteConfig["cms"] = {
143
158
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
144
159
  domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
160
+ domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
145
161
  staging: cmsRecord.staging as boolean | undefined,
146
162
  cognitoDomainPrefix: requireString(
147
163
  cmsRecord.cognitoDomainPrefix,
@@ -149,9 +165,11 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
149
165
  COGNITO_DOMAIN_PREFIX_PATTERN,
150
166
  "lowercase letters, digits and hyphens; globally unique per region",
151
167
  ),
168
+ applyCognitoDomainPrefix: cmsRecord.applyCognitoDomainPrefix as boolean | undefined,
152
169
  buildComputeType: buildComputeType as "STANDARD" | "STANDARD_8GB" | undefined,
153
170
  environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
154
171
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
172
+ stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
155
173
  };
156
174
 
157
175
  const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
@@ -170,6 +188,24 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
170
188
  if (cms.stagingDomainName && !cms.domainName) {
171
189
  fail("cms.stagingDomainName", "requires cms.domainName");
172
190
  }
191
+ if (cms.domainPrefix && !cms.domainName) {
192
+ fail("cms.domainPrefix", "requires cms.domainName (the root domain)");
193
+ }
194
+ if (cms.stagingDomainPrefix && !cms.domainPrefix) {
195
+ fail("cms.stagingDomainPrefix", "requires cms.domainPrefix");
196
+ }
197
+ if (cms.domainPrefix && cms.stagingDomainName) {
198
+ fail("cms.stagingDomainName", "must not be set with cms.domainPrefix: use cms.stagingDomainPrefix");
199
+ }
200
+ if (cms.staging === false && cms.stagingDomainPrefix) {
201
+ fail("cms.stagingDomainPrefix", "must not be set when cms.staging is false");
202
+ }
203
+ if (cms.domainPrefix && cms.staging !== false && !cms.stagingDomainPrefix) {
204
+ fail("cms.stagingDomainPrefix", "is required when cms.domainPrefix is set and staging is enabled: the staging host is never derived under a shared root domain");
205
+ }
206
+ if (cms.domainPrefix && cms.stagingDomainPrefix === cms.domainPrefix) {
207
+ fail("cms.stagingDomainPrefix", "must differ from cms.domainPrefix");
208
+ }
173
209
 
174
210
  let reader: AmplifyAppShellSiteConfig["reader"];
175
211
  if (record.reader !== undefined) {
@@ -241,7 +277,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
241
277
  const redirectUrls = redirectValue.split(",").map((url) => url.trim());
242
278
  const stagingDomainName = resolveStagingDomainName(config);
243
279
  const requiredOrigins = [
244
- ...(cms.domainName ? [`https://${cms.domainName}/`] : []),
280
+ ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}/`] : []),
245
281
  ...(stagingDomainName ? [`https://${stagingDomainName}/`] : []),
246
282
  LOCAL_DEVELOPMENT_ORIGIN,
247
283
  ];
@@ -0,0 +1,459 @@
1
+ import type { ArticleImageAsset } from "./articles";
2
+ import type { PublicationItem } from "./publication-items";
3
+ import {
4
+ createDefaultBlogVerticalRhythm,
5
+ getMeasuredTextHeight,
6
+ reserveRhythmRows,
7
+ snapDownToRhythm,
8
+ snapPreferredHeightToRhythm,
9
+ snapPreservedImageHeightToRhythm,
10
+ type BlogTextStyle,
11
+ type VerticalRhythm,
12
+ } from "./blog-rhythm";
13
+ import { layoutAllTextLines, layoutTextLines, prepareWithSegments, type TextLine, type TextObstacle } from "./pretext-layout";
14
+
15
+ export type FeaturedLayoutMode = "obstacle" | "float" | "stacked";
16
+
17
+ export type SolvedFeaturedItem = {
18
+ mode: FeaturedLayoutMode;
19
+ textLines: TextLine[];
20
+ imageHeight: number;
21
+ imageWidth: number;
22
+ /** Image frame + caption/credit block reserved below the frame. */
23
+ mediaHeight: number;
24
+ captionHeight: number;
25
+ copyWidth: number;
26
+ textFrameHeight: number;
27
+ hasMore: boolean;
28
+ gap: number;
29
+ };
30
+
31
+ export type SolvedFeaturedFloatGeometry = {
32
+ mode: "float";
33
+ imageHeight: number;
34
+ imageWidth: number;
35
+ mediaHeight: number;
36
+ captionHeight: number;
37
+ copyWidth: number;
38
+ gap: number;
39
+ };
40
+
41
+ export type SolveFeaturedItemInput = {
42
+ text: string;
43
+ containerWidth: number;
44
+ viewportWidth: number;
45
+ rhythm?: VerticalRhythm;
46
+ textStyle: BlogTextStyle;
47
+ imageAsset: ArticleImageAsset;
48
+ itemIndex?: number;
49
+ };
50
+
51
+ export type SolveFeaturedFloatGeometryInput = {
52
+ containerWidth: number;
53
+ viewportWidth: number;
54
+ rhythm?: VerticalRhythm;
55
+ imageAsset: Pick<ArticleImageAsset, "layout" | "caption" | "credit">;
56
+ itemIndex?: number;
57
+ /** Fraction of container width for the image. Defaults to the edition-index teaser ratio. */
58
+ imageWidthRatio?: number;
59
+ /** Fraction of viewport width used as a hard image-width cap. Defaults to 1/3. */
60
+ viewportImageCapRatio?: number;
61
+ /** Absolute float width cap in rhythm rows. Stops percentage sizing from outgrowing the rail. */
62
+ maxWidthRows?: number;
63
+ };
64
+
65
+ const FLOAT_IMAGE_WIDTH_RATIO = 0.32;
66
+ /** Article pages use a larger float so the pictogram reads as a feature, not a teaser. */
67
+ const ARTICLE_FLOAT_IMAGE_WIDTH_RATIO = 0.48;
68
+ const ARTICLE_VIEWPORT_IMAGE_CAP_RATIO = 0.5;
69
+ /** Edition-index floats track the pictogram maxHeight band (~440px at 16px rows). */
70
+ const FLOAT_IMAGE_MAX_WIDTH_ROWS = 27;
71
+ /** Article floats read larger than teasers but must not keep growing on wide rails. */
72
+ const ARTICLE_FLOAT_IMAGE_MAX_WIDTH_ROWS = 24;
73
+ const FLOAT_IMAGE_MIN_PHONE_ROWS = 6;
74
+ const FLOAT_IMAGE_MIN_DESKTOP_ROWS = 8;
75
+ const FLOAT_COPY_MIN_PHONE_ROWS = 11;
76
+ const FLOAT_COPY_MIN_DESKTOP_ROWS = 14;
77
+ const LEAD_GAP_ROWS = 2;
78
+ const LEAD_GAP_ROWS_NARROW = 1;
79
+ const DEFAULT_GAP_ROWS = 4;
80
+
81
+ export function getFeaturedLayoutMode(viewportWidth: number, hasImage: boolean): FeaturedLayoutMode {
82
+ if (!hasImage) return "stacked";
83
+ return "float";
84
+ }
85
+
86
+ export function solveFeaturedItem(input: SolveFeaturedItemInput): SolvedFeaturedItem {
87
+ const rhythm = input.rhythm ?? createDefaultBlogVerticalRhythm();
88
+ const gap = getFeaturedLayoutGap(input.viewportWidth, input.itemIndex, rhythm);
89
+ const mode = getFeaturedLayoutMode(input.viewportWidth, true);
90
+ const prepared = prepareWithSegments(input.text, `${input.textStyle.fontSize}px ${input.textStyle.fontFamily}`, {
91
+ whiteSpace: "pre-wrap",
92
+ });
93
+ const aspectRatio = getImageAspectRatio(input.imageAsset);
94
+
95
+ if (mode === "obstacle") {
96
+ return solveObstacleFeaturedItem({
97
+ prepared,
98
+ containerWidth: input.containerWidth,
99
+ viewportWidth: input.viewportWidth,
100
+ rhythm,
101
+ textStyle: input.textStyle,
102
+ aspectRatio,
103
+ gap,
104
+ imageAsset: input.imageAsset,
105
+ });
106
+ }
107
+
108
+ if (mode === "float") {
109
+ return solveFloatFeaturedItem({
110
+ prepared,
111
+ containerWidth: input.containerWidth,
112
+ viewportWidth: input.viewportWidth,
113
+ rhythm,
114
+ textStyle: input.textStyle,
115
+ aspectRatio,
116
+ gap,
117
+ imageAsset: input.imageAsset,
118
+ });
119
+ }
120
+
121
+ const lines = layoutAllTextLines({
122
+ prepared,
123
+ maxWidth: input.containerWidth,
124
+ ...input.textStyle,
125
+ });
126
+ const imageWidth = snapDownToRhythm(Math.round(input.containerWidth), rhythm);
127
+ const imageHeight = snapImageHeight(imageWidth / aspectRatio, rhythm, input.imageAsset.layout);
128
+ const captionHeight = getFeatureCaptionHeight(input.imageAsset, imageWidth, rhythm);
129
+ return {
130
+ mode,
131
+ textLines: lines,
132
+ imageHeight,
133
+ imageWidth,
134
+ captionHeight,
135
+ mediaHeight: imageHeight + captionHeight,
136
+ copyWidth: input.containerWidth,
137
+ textFrameHeight: reserveRhythmRows(getMeasuredTextHeight(lines), rhythm),
138
+ hasMore: false,
139
+ gap,
140
+ };
141
+ }
142
+
143
+ type PreparedSolveInput = {
144
+ prepared: ReturnType<typeof prepareWithSegments>;
145
+ containerWidth: number;
146
+ viewportWidth: number;
147
+ rhythm: VerticalRhythm;
148
+ textStyle: BlogTextStyle;
149
+ aspectRatio: number;
150
+ gap: number;
151
+ imageAsset: ArticleImageAsset;
152
+ };
153
+
154
+ function solveObstacleFeaturedItem(input: PreparedSolveInput): SolvedFeaturedItem {
155
+ const maxImageWidth = getFloatImageWidth(input);
156
+ let best: {
157
+ score: number;
158
+ textLines: TextLine[];
159
+ imageHeight: number;
160
+ imageWidth: number;
161
+ captionHeight: number;
162
+ mediaHeight: number;
163
+ copyWidth: number;
164
+ textFrameHeight: number;
165
+ hasMore: boolean;
166
+ } | null = null;
167
+
168
+ for (const imageWidth of getObstacleImageWidths(maxImageWidth, input.rhythm)) {
169
+ const copyWidth = Math.max(
170
+ getFloatCopyMinimum(input.viewportWidth, input.rhythm),
171
+ input.containerWidth - imageWidth - input.gap,
172
+ );
173
+ const imageHeight = snapImageHeight(imageWidth / input.aspectRatio, input.rhythm, input.imageAsset.layout);
174
+ const captionHeight = getFeatureCaptionHeight(input.imageAsset, imageWidth, input.rhythm);
175
+ const mediaHeight = imageHeight + captionHeight;
176
+ const obstacle: TextObstacle = {
177
+ x: Math.max(0, copyWidth - imageWidth),
178
+ y: 0,
179
+ width: imageWidth,
180
+ height: mediaHeight,
181
+ };
182
+
183
+ let candidateHeight = mediaHeight;
184
+ let result = layoutTextLines({
185
+ prepared: input.prepared,
186
+ cursor: { segmentIndex: 0, graphemeIndex: 0 },
187
+ maxHeight: candidateHeight,
188
+ maxWidth: copyWidth,
189
+ lineHeight: input.textStyle.lineHeight,
190
+ linePaintHeight: input.textStyle.linePaintHeight,
191
+ fontSize: input.textStyle.fontSize,
192
+ fontFamily: input.textStyle.fontFamily,
193
+ obstacles: [obstacle],
194
+ });
195
+
196
+ const textBottom = getMeasuredTextHeight(result.lines);
197
+ if (textBottom > 0 && textBottom < candidateHeight) {
198
+ candidateHeight = reserveRhythmRows(textBottom, input.rhythm);
199
+ obstacle.height = candidateHeight;
200
+ result = layoutTextLines({
201
+ prepared: input.prepared,
202
+ cursor: { segmentIndex: 0, graphemeIndex: 0 },
203
+ maxHeight: candidateHeight,
204
+ maxWidth: copyWidth,
205
+ lineHeight: input.textStyle.lineHeight,
206
+ linePaintHeight: input.textStyle.linePaintHeight,
207
+ fontSize: input.textStyle.fontSize,
208
+ fontFamily: input.textStyle.fontFamily,
209
+ obstacles: [obstacle],
210
+ });
211
+ }
212
+
213
+ const whitespace = Math.max(0, candidateHeight - getMeasuredTextHeight(result.lines));
214
+ const score = 50_000 - whitespace * 1.4 - Math.abs(candidateHeight - mediaHeight) * 0.35 - Math.abs(maxImageWidth - imageWidth) * 0.2;
215
+ const candidate = {
216
+ score,
217
+ textLines: result.lines,
218
+ imageHeight: Math.min(imageHeight, candidateHeight),
219
+ imageWidth,
220
+ captionHeight,
221
+ mediaHeight: candidateHeight,
222
+ copyWidth,
223
+ textFrameHeight: reserveRhythmRows(getMeasuredTextHeight(result.lines), input.rhythm),
224
+ hasMore: result.hasMore,
225
+ };
226
+ if (!best || candidate.score > best.score) best = candidate;
227
+ }
228
+
229
+ const fallbackImageHeight = input.rhythm.rowHeight * 6;
230
+ return {
231
+ mode: "obstacle",
232
+ textLines: best?.textLines ?? [],
233
+ imageHeight: best?.imageHeight ?? fallbackImageHeight,
234
+ imageWidth: best?.imageWidth ?? maxImageWidth,
235
+ captionHeight: best?.captionHeight ?? 0,
236
+ mediaHeight: best?.mediaHeight ?? fallbackImageHeight,
237
+ copyWidth: best?.copyWidth ?? input.containerWidth,
238
+ textFrameHeight: best?.textFrameHeight ?? 0,
239
+ hasMore: best?.hasMore ?? false,
240
+ gap: input.gap,
241
+ };
242
+ }
243
+
244
+ function solveFloatFeaturedItem(input: PreparedSolveInput): SolvedFeaturedItem {
245
+ const geometry = computeFloatGeometry({
246
+ containerWidth: input.containerWidth,
247
+ viewportWidth: input.viewportWidth,
248
+ rhythm: input.rhythm,
249
+ gap: input.gap,
250
+ aspectRatio: input.aspectRatio,
251
+ imageAsset: input.imageAsset,
252
+ });
253
+ // Full-width frame with the image+caption as a top-right obstacle so excerpt
254
+ // lines shorten beside the media block and resume full width below it.
255
+ const frameWidth = input.containerWidth;
256
+ const obstacle: TextObstacle = {
257
+ x: Math.max(0, frameWidth - geometry.imageWidth - geometry.gap),
258
+ y: 0,
259
+ width: geometry.imageWidth + geometry.gap,
260
+ height: geometry.mediaHeight,
261
+ };
262
+ const lines = layoutAllTextLines({
263
+ prepared: input.prepared,
264
+ maxWidth: frameWidth,
265
+ lineHeight: input.textStyle.lineHeight,
266
+ linePaintHeight: input.textStyle.linePaintHeight,
267
+ fontSize: input.textStyle.fontSize,
268
+ fontFamily: input.textStyle.fontFamily,
269
+ obstacles: [obstacle],
270
+ });
271
+
272
+ return {
273
+ mode: "float",
274
+ textLines: lines,
275
+ imageHeight: geometry.imageHeight,
276
+ imageWidth: geometry.imageWidth,
277
+ captionHeight: geometry.captionHeight,
278
+ mediaHeight: geometry.mediaHeight,
279
+ copyWidth: frameWidth,
280
+ textFrameHeight: reserveRhythmRows(getMeasuredTextHeight(lines), input.rhythm),
281
+ hasMore: false,
282
+ gap: geometry.gap,
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Geometry-only float solve for article pages (and any consumer that renders
288
+ * flowing body copy rather than Pretext-measured excerpt lines). Shares the
289
+ * same width/height/gap math as the edition-index lead float.
290
+ */
291
+ export function solveFeaturedFloatGeometry(input: SolveFeaturedFloatGeometryInput): SolvedFeaturedFloatGeometry {
292
+ const rhythm = input.rhythm ?? createDefaultBlogVerticalRhythm();
293
+ const gap = getFeaturedLayoutGap(input.viewportWidth, input.itemIndex, rhythm);
294
+ return {
295
+ mode: "float",
296
+ ...computeFloatGeometry({
297
+ containerWidth: input.containerWidth,
298
+ viewportWidth: input.viewportWidth,
299
+ rhythm,
300
+ gap,
301
+ aspectRatio: getImageAspectRatio(input.imageAsset),
302
+ imageAsset: input.imageAsset,
303
+ // Article pages default larger than edition teasers; callers can override.
304
+ imageWidthRatio: input.imageWidthRatio ?? ARTICLE_FLOAT_IMAGE_WIDTH_RATIO,
305
+ viewportImageCapRatio: input.viewportImageCapRatio ?? ARTICLE_VIEWPORT_IMAGE_CAP_RATIO,
306
+ maxWidthRows: input.maxWidthRows ?? ARTICLE_FLOAT_IMAGE_MAX_WIDTH_ROWS,
307
+ }),
308
+ };
309
+ }
310
+
311
+ type FloatGeometryInput = {
312
+ containerWidth: number;
313
+ viewportWidth: number;
314
+ rhythm: VerticalRhythm;
315
+ gap: number;
316
+ aspectRatio: number;
317
+ imageAsset: Pick<ArticleImageAsset, "caption" | "credit" | "layout">;
318
+ imageWidthRatio?: number;
319
+ viewportImageCapRatio?: number;
320
+ maxWidthRows?: number;
321
+ };
322
+
323
+ function computeFloatGeometry(input: FloatGeometryInput): Omit<SolvedFeaturedFloatGeometry, "mode"> {
324
+ let imageWidth = getFloatImageWidth(input);
325
+ // Keep the frame proportional when layout.maxHeight would otherwise clip a
326
+ // wide float down to the minimum height (snapPreferredHeightToRhythm falls
327
+ // back to min when natural height exceeds max).
328
+ const maxHeight = input.imageAsset.layout?.maxHeight ?? input.rhythm.rowHeight * 36;
329
+ const widthFromMaxHeight = snapDownToRhythm(maxHeight * input.aspectRatio, input.rhythm);
330
+ if (widthFromMaxHeight > 0) {
331
+ imageWidth = Math.min(imageWidth, widthFromMaxHeight);
332
+ }
333
+ const absoluteMaxWidth = rhythmLengthRows(input.maxWidthRows ?? FLOAT_IMAGE_MAX_WIDTH_ROWS, input.rhythm);
334
+ imageWidth = Math.min(imageWidth, absoluteMaxWidth);
335
+ const copyWidth = Math.max(
336
+ getFloatCopyMinimum(input.viewportWidth, input.rhythm),
337
+ input.containerWidth - imageWidth - input.gap,
338
+ );
339
+ const imageHeight = snapImageHeight(imageWidth / input.aspectRatio, input.rhythm, input.imageAsset.layout);
340
+ const captionHeight = getFeatureCaptionHeight(input.imageAsset, imageWidth, input.rhythm);
341
+ return {
342
+ imageHeight,
343
+ imageWidth,
344
+ captionHeight,
345
+ mediaHeight: imageHeight + captionHeight,
346
+ copyWidth,
347
+ gap: input.gap,
348
+ };
349
+ }
350
+
351
+ /** Matches the blog figcaption: meta size, one-row line box, rhythm-unit margin-top. */
352
+ const FEATURE_CAPTION_FONT_SIZE = 12;
353
+
354
+ function getFeatureCaptionHeight(
355
+ asset: Pick<ArticleImageAsset, "caption" | "credit">,
356
+ imageWidth: number,
357
+ rhythm: VerticalRhythm,
358
+ ): number {
359
+ const caption = (asset.caption ?? asset.credit ?? "").trim();
360
+ if (!caption) return 0;
361
+ const fontFamily = 'Georgia, "Times New Roman", serif';
362
+ const prepared = prepareWithSegments(caption, `${FEATURE_CAPTION_FONT_SIZE}px ${fontFamily}`, {
363
+ whiteSpace: "pre-wrap",
364
+ });
365
+ const lines = layoutAllTextLines({
366
+ prepared,
367
+ maxWidth: Math.max(1, imageWidth),
368
+ lineHeight: rhythm.rowHeight,
369
+ linePaintHeight: rhythm.rowHeight,
370
+ fontSize: FEATURE_CAPTION_FONT_SIZE,
371
+ fontFamily,
372
+ });
373
+ const textHeight = Math.max(rhythm.rowHeight, getMeasuredTextHeight(lines));
374
+ // Brand CSS: figcaption margin-top is one rhythm unit, a quarter of a row.
375
+ const marginTop = rhythm.rowHeight / 4;
376
+ return reserveRhythmRows(marginTop + textHeight, rhythm);
377
+ }
378
+
379
+ function getFeaturedLayoutGap(viewportWidth: number, itemIndex: number | undefined, rhythm: VerticalRhythm): number {
380
+ if (itemIndex === 0) {
381
+ return rhythmLengthRows(viewportWidth <= 600 ? LEAD_GAP_ROWS_NARROW : LEAD_GAP_ROWS, rhythm);
382
+ }
383
+ return rhythmLengthRows(DEFAULT_GAP_ROWS, rhythm);
384
+ }
385
+
386
+ function getFloatImageWidth(
387
+ input: Pick<
388
+ FloatGeometryInput,
389
+ "containerWidth" | "viewportWidth" | "rhythm" | "gap" | "imageWidthRatio" | "viewportImageCapRatio" | "maxWidthRows"
390
+ >,
391
+ ): number {
392
+ const imageWidthRatio = input.imageWidthRatio ?? FLOAT_IMAGE_WIDTH_RATIO;
393
+ const viewportImageCapRatio = input.viewportImageCapRatio ?? (1 / 3);
394
+ const preferred = snapDownToRhythm(Math.round(input.containerWidth * imageWidthRatio), input.rhythm);
395
+ const viewportCap = snapDownToRhythm(Math.floor(input.viewportWidth * viewportImageCapRatio), input.rhythm);
396
+ const copyCap = snapDownToRhythm(
397
+ Math.max(0, input.containerWidth - input.gap - getFloatCopyMinimum(input.viewportWidth, input.rhythm)),
398
+ input.rhythm,
399
+ );
400
+ const absoluteMaxWidth = rhythmLengthRows(input.maxWidthRows ?? FLOAT_IMAGE_MAX_WIDTH_ROWS, input.rhythm);
401
+ const softMinimum = getFloatImageMinimum(input.viewportWidth, input.rhythm);
402
+ const hardMinimum = rhythmLengthRows(5, input.rhythm);
403
+ const hardMaximum = Math.max(
404
+ hardMinimum,
405
+ Math.min(absoluteMaxWidth, viewportCap, copyCap > 0 ? copyCap : viewportCap),
406
+ );
407
+
408
+ if (hardMaximum <= softMinimum) return hardMaximum;
409
+ return clamp(Math.min(preferred, hardMaximum), softMinimum, hardMaximum);
410
+ }
411
+
412
+ function getFloatImageMinimum(viewportWidth: number, rhythm: VerticalRhythm): number {
413
+ return rhythmLengthRows(viewportWidth <= 600 ? FLOAT_IMAGE_MIN_PHONE_ROWS : FLOAT_IMAGE_MIN_DESKTOP_ROWS, rhythm);
414
+ }
415
+
416
+ function getFloatCopyMinimum(viewportWidth: number, rhythm: VerticalRhythm): number {
417
+ return rhythmLengthRows(viewportWidth <= 600 ? FLOAT_COPY_MIN_PHONE_ROWS : FLOAT_COPY_MIN_DESKTOP_ROWS, rhythm);
418
+ }
419
+
420
+ function getObstacleImageWidths(maximum: number, rhythm: VerticalRhythm): number[] {
421
+ const candidateRows = [0, 1, 2].map((offset) => Math.max(rhythm.rowHeight * 5, maximum - (offset * rhythm.rowHeight)));
422
+ return Array.from(new Set(candidateRows));
423
+ }
424
+
425
+ function snapImageHeight(
426
+ naturalHeight: number,
427
+ rhythm: VerticalRhythm,
428
+ layout?: ArticleImageAsset["layout"],
429
+ ): number {
430
+ const maxHeight = layout?.maxHeight ?? rhythm.rowHeight * 36;
431
+ // Cover may grow the frame to layout.minHeight. Contain/preserve must not —
432
+ // on narrow viewports the chosen width implies a short natural height, and
433
+ // stretching to minHeight leaves an empty outlined box around the pictogram.
434
+ if (layout?.crop === "cover") {
435
+ const minHeight = layout.minHeight ?? rhythm.rowHeight * 6;
436
+ const clampedHeight = clamp(naturalHeight, minHeight, maxHeight);
437
+ return snapPreferredHeightToRhythm(clampedHeight, rhythm, minHeight, maxHeight);
438
+ }
439
+ const hardMinimum = rhythm.rowHeight;
440
+ const capped = clamp(naturalHeight, hardMinimum, maxHeight);
441
+ return snapPreservedImageHeightToRhythm(capped, rhythm, hardMinimum, maxHeight);
442
+ }
443
+
444
+ function getImageAspectRatio(asset: Pick<ArticleImageAsset, "layout">): number {
445
+ if (asset.layout?.aspectRatio) return asset.layout.aspectRatio;
446
+ return 1;
447
+ }
448
+
449
+ function rhythmLengthRows(rows: number, rhythm: VerticalRhythm): number {
450
+ return rows * rhythm.rowHeight;
451
+ }
452
+
453
+ function clamp(value: number, min: number, max: number): number {
454
+ return Math.max(min, Math.min(max, value));
455
+ }
456
+
457
+ export function isFeaturedBlogItem(item: PublicationItem, index?: number, mode?: string): boolean {
458
+ return mode === "blog" && index === 0 && Boolean(item.type === "article" || item.type === "brief");
459
+ }