@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.
- package/amplify/auth/resource.js +17 -4
- package/amplify/auth/resource.ts +21 -4
- package/amplify/site-backend-config.ts +6 -1
- package/amplify/site-backend.js +9 -2
- package/amplify/site-backend.ts +12 -2
- package/app/api/revalidate/route.ts +5 -4
- package/app/page.tsx +5 -2
- package/components/newspaper.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/settings-page.tsx +2 -1
- package/infra/amplify-app-shell.js +23 -8
- package/infra/amplify-app-shell.ts +23 -8
- package/infra/build-specs.js +2 -1
- package/infra/build-specs.ts +4 -2
- package/infra/site-config.js +36 -3
- package/infra/site-config.ts +39 -3
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-nav.ts +1 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +7 -3
- package/lib/reader-base-path.ts +24 -0
- package/lib/site-brand.ts +2 -0
- package/lib/staging-gated-path.ts +22 -0
- package/middleware.ts +5 -16
- package/package.json +5 -1
- package/routes.manifest.json +9 -1
- package/src/sync.mjs +28 -4
package/infra/site-config.js
CHANGED
|
@@ -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
|
-
...(
|
|
222
|
+
...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}/`] : []),
|
|
190
223
|
...(stagingDomainName ? [`https://${stagingDomainName}/`] : []),
|
|
191
224
|
LOCAL_DEVELOPMENT_ORIGIN,
|
|
192
225
|
];
|
package/infra/site-config.ts
CHANGED
|
@@ -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
|
-
...(
|
|
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
|
+
}
|