@anthusai/papyrus 1.2.0 → 1.4.0-next.1
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/functions/model-attachment-upload/handler.ts +1 -0
- package/app/newsroom/[section]/page.tsx +6 -0
- package/app/newsroom/articles/page.tsx +3 -0
- package/components/news-desk-client-provider.tsx +17 -2
- package/components/news-desk-taxonomy-client.ts +133 -2
- package/components/newspaper.tsx +4 -4
- package/components/newsroom-board-view.tsx +74 -50
- package/components/newsroom-client-shell.tsx +8 -1
- package/components/newsroom-organizer-shell.tsx +12 -7
- package/components/newsroom-organizer.css +4 -3
- package/components/newsroom-references-view.tsx +18 -0
- package/components/reference-cyclotron-priming.tsx +85 -0
- package/components/topic-steering-workspace.tsx +35 -1
- package/lib/article-flow.ts +95 -0
- package/lib/article-frame-solver.ts +519 -0
- package/lib/chrome-text.ts +504 -0
- package/lib/content-item-lifecycle.ts +25 -26
- package/lib/front-mosaic.ts +1132 -0
- package/lib/furniture.ts +385 -0
- package/lib/labels.ts +37 -0
- package/lib/layout-config.ts +237 -0
- package/lib/layout-plan.ts +37 -9
- package/lib/layout-scenarios.ts +21 -11
- package/lib/layout-types.ts +457 -0
- package/lib/local-preview-seed.ts +97 -0
- package/lib/local-preview.ts +3 -0
- package/lib/newspaper-layout.ts +17 -3410
- package/lib/newsroom-board.ts +63 -62
- package/lib/newsroom-organizer-nav.ts +3 -4
- package/lib/pretext-layout.ts +22 -0
- package/lib/relevance-status.ts +73 -0
- package/lib/site-brand.ts +15 -1
- package/lib/text-columns.ts +251 -0
- package/package.json +13 -2
- package/renderers/pretext/presentation-shell.tsx +1 -1
- package/routes.manifest.json +1 -9
- package/app/newsroom/work/page.tsx +0 -10
package/lib/newsroom-board.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
|
|
2
|
-
import type { AssignmentRecord
|
|
2
|
+
import type { AssignmentRecord } from "./category-repository";
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
contentItemStageKeys,
|
|
5
5
|
contentItemStageLabel,
|
|
6
6
|
latestVersionPerContentItem,
|
|
7
7
|
type ContentItemVersionRecord,
|
|
8
8
|
} from "./content-item-lifecycle";
|
|
9
|
-
import {
|
|
9
|
+
import { assignmentStatusLabel } from "./newsroom-assignments";
|
|
10
|
+
import { getContentItemName, getContentItemStageDefinitions } from "./site-brand";
|
|
10
11
|
|
|
11
|
-
export type NativeBoardKind = "contentItem" | "
|
|
12
|
+
export type NativeBoardKind = "contentItem" | "assignment";
|
|
12
13
|
export interface NativeBoardIssue extends KanbanIssue {
|
|
13
14
|
kind: NativeBoardKind;
|
|
14
15
|
nativeId: string;
|
|
@@ -20,18 +21,14 @@ export function contentItemBoardType(): string {
|
|
|
20
21
|
return getContentItemName().singular.toLowerCase();
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
export const ASSIGNMENT_BOARD_TYPE = "assignment";
|
|
25
|
+
|
|
26
|
+
const ASSIGNMENT_STATUSES_BEFORE_OTHERS = ["open", "claimed"];
|
|
27
|
+
const ASSIGNMENT_STATUSES_AFTER_OTHERS = ["completed", "canceled"];
|
|
26
28
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* identity and native status.
|
|
31
|
-
*/
|
|
32
|
-
export function projectNewsroomBoard(records: {
|
|
33
|
-
contentItemVersions?: readonly ContentItemVersionRecord[];
|
|
34
|
-
references?: readonly ReferenceRecord[];
|
|
29
|
+
/** One card per content item, in the column for its lifecycle stage; its assignments are counted on the card. */
|
|
30
|
+
export function projectContentItemBoard(records: {
|
|
31
|
+
contentItemVersions: readonly ContentItemVersionRecord[];
|
|
35
32
|
assignments?: readonly AssignmentRecord[];
|
|
36
33
|
}): NativeBoardIssue[] {
|
|
37
34
|
const assignmentCountByContentItem = new Map<string, number>();
|
|
@@ -41,62 +38,66 @@ export function projectNewsroomBoard(records: {
|
|
|
41
38
|
}
|
|
42
39
|
}
|
|
43
40
|
const contentItemType = contentItemBoardType();
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}),
|
|
55
|
-
...(records.references ?? []).filter(row => !row.versionState || row.versionState === "current").map(row => ({
|
|
56
|
-
id: `reference:${row.id}`, nativeId: row.id, kind: "reference" as const, type: "reference",
|
|
57
|
-
title: row.title || row.externalItemId || row.id,
|
|
58
|
-
nativeStatus: row.curationStatus || "pending", status: `reference/${row.curationStatus || "pending"}`,
|
|
59
|
-
priority: 2, description: row.curationStatusReason || undefined,
|
|
60
|
-
updated_at: row.updatedAt || row.importedAt || undefined,
|
|
61
|
-
href: `/newsroom/references/${encodeURIComponent(row.lineageId || row.id)}`,
|
|
62
|
-
})),
|
|
63
|
-
...(records.assignments ?? []).filter(row => !row.itemLineageId).map(row => ({
|
|
64
|
-
id: `assignment:${row.id}`, nativeId: row.id, kind: "assignment" as const, type: "assignment",
|
|
65
|
-
title: row.title, nativeStatus: row.status, status: `assignment/${row.status}`,
|
|
66
|
-
priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
|
|
67
|
-
description: row.summary || row.brief || undefined,
|
|
68
|
-
created_at: row.createdAt, updated_at: row.updatedAt,
|
|
69
|
-
href: `/newsroom/assignments/${encodeURIComponent(row.id)}`,
|
|
70
|
-
})),
|
|
71
|
-
];
|
|
41
|
+
return latestVersionPerContentItem(records.contentItemVersions).map(version => {
|
|
42
|
+
const stage = version.stage ?? "idea";
|
|
43
|
+
const workCount = assignmentCountByContentItem.get(version.lineageId) ?? 0;
|
|
44
|
+
return {
|
|
45
|
+
id: `contentItem:${version.lineageId}`, nativeId: version.lineageId, kind: "contentItem" as const, type: contentItemType,
|
|
46
|
+
title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
|
|
47
|
+
priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
|
|
48
|
+
updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
72
51
|
}
|
|
73
52
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
53
|
+
/** One card per assignment, in the column for its status. */
|
|
54
|
+
export function projectAssignmentBoard(assignments: readonly AssignmentRecord[]): NativeBoardIssue[] {
|
|
55
|
+
return assignments.map(row => ({
|
|
56
|
+
id: `assignment:${row.id}`, nativeId: row.id, kind: "assignment" as const, type: ASSIGNMENT_BOARD_TYPE,
|
|
57
|
+
title: row.title, nativeStatus: row.status, status: `${ASSIGNMENT_BOARD_TYPE}/${row.status}`,
|
|
58
|
+
priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
|
|
59
|
+
description: row.summary || row.brief || undefined,
|
|
60
|
+
created_at: row.createdAt, updated_at: row.updatedAt,
|
|
61
|
+
href: `/newsroom/assignments/${encodeURIComponent(row.id)}`,
|
|
62
|
+
}));
|
|
78
63
|
}
|
|
79
64
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
65
|
+
export function contentItemColumnOrder(
|
|
66
|
+
stagesPresentOnBoard: readonly string[],
|
|
67
|
+
stageKeys: readonly string[] = contentItemStageKeys(getContentItemStageDefinitions()),
|
|
68
|
+
): string[] {
|
|
69
|
+
const lastStage = stageKeys[stageKeys.length - 1];
|
|
70
|
+
const unrecognizedStages = [...new Set(stagesPresentOnBoard)].filter(stage => !stageKeys.includes(stage));
|
|
71
|
+
return [...stageKeys.filter(stage => stage !== lastStage), ...unrecognizedStages, lastStage];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function assignmentColumnOrder(statusesPresentOnBoard: readonly string[]): string[] {
|
|
75
|
+
const knownStatuses = [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
|
|
76
|
+
const otherStatuses = [...new Set(statusesPresentOnBoard)].filter(status => !knownStatuses.includes(status));
|
|
77
|
+
return [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...otherStatuses, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function boardConfigForType(type: string, columns: readonly { key: string; name: string }[]): KanbanConfig {
|
|
81
|
+
const statuses = columns.map(column => ({ ...column, category: "native" }));
|
|
89
82
|
return {
|
|
90
|
-
hierarchy: [], types:
|
|
91
|
-
statuses,
|
|
83
|
+
hierarchy: [], types: [type], statuses,
|
|
92
84
|
categories: [{ name: "native" }], priorities: {}, type_colors: {},
|
|
93
|
-
workflows: Object.fromEntries(
|
|
94
|
-
const type = boardTypeForKind(kind);
|
|
95
|
-
return [type, Object.fromEntries(statuses.filter(status => status.key.startsWith(`${type}/`)).map(status => [status.key, []]))];
|
|
96
|
-
})),
|
|
85
|
+
workflows: { [type]: Object.fromEntries(statuses.map(status => [status.key, []])) },
|
|
97
86
|
};
|
|
98
87
|
}
|
|
99
88
|
|
|
89
|
+
/** Content items read left to right through the lifecycle with the final stage last. */
|
|
90
|
+
export function contentItemBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
|
|
91
|
+
const contentItemType = contentItemBoardType();
|
|
92
|
+
return boardConfigForType(contentItemType, contentItemColumnOrder(issues.map(issue => issue.nativeStatus))
|
|
93
|
+
.map(stage => ({ key: `${contentItemType}/${stage}`, name: contentItemStageLabel(stage, getContentItemStageDefinitions()) })));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function assignmentBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
|
|
97
|
+
return boardConfigForType(ASSIGNMENT_BOARD_TYPE, assignmentColumnOrder(issues.map(issue => issue.nativeStatus))
|
|
98
|
+
.map(status => ({ key: `${ASSIGNMENT_BOARD_TYPE}/${status}`, name: assignmentStatusLabel(status) })));
|
|
99
|
+
}
|
|
100
|
+
|
|
100
101
|
export function canReadNewsroomBoard(phase: string | undefined): boolean {
|
|
101
102
|
return phase === "ready";
|
|
102
103
|
}
|
|
@@ -8,9 +8,8 @@ export type OrganizerDestination = {
|
|
|
8
8
|
export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
|
|
9
9
|
{ title: "Editorial work", items: [
|
|
10
10
|
{ key: "overview", label: "Overview", description: "See the desk and choose where to begin.", path: "/newsroom", primary: true },
|
|
11
|
-
{ key: "
|
|
12
|
-
{ key: "
|
|
13
|
-
{ key: "assignments", label: "Assignments", description: "Claim work and follow research and reporting progress.", path: "/newsroom/assignments" },
|
|
11
|
+
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: "/newsroom/articles", primary: true },
|
|
12
|
+
{ key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
|
|
14
13
|
{ key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
|
|
15
14
|
{ key: "insights", label: "Insights", description: "Discuss findings and research threads.", path: "/newsroom/insights" },
|
|
16
15
|
] },
|
|
@@ -30,7 +29,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
|
|
|
30
29
|
] },
|
|
31
30
|
];
|
|
32
31
|
const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
|
|
33
|
-
export const ORGANIZER_PRIMARY_NAV = ["overview", "
|
|
32
|
+
export const ORGANIZER_PRIMARY_NAV = ["overview", "articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
|
|
34
33
|
|
|
35
34
|
export const ORGANIZER_SETTINGS_DESTINATION = {
|
|
36
35
|
label: "Settings", path: "/newsroom/settings", description: "Theme, motion and interface preferences.",
|
package/lib/pretext-layout.ts
CHANGED
|
@@ -9,6 +9,27 @@ import {
|
|
|
9
9
|
|
|
10
10
|
export { layoutNextLine, prepareWithSegments, type LayoutCursor, type PreparedTextWithSegments };
|
|
11
11
|
|
|
12
|
+
const preparedTextCache = new Map<string, PreparedTextWithSegments>();
|
|
13
|
+
let preparedTextCacheMissCount = 0;
|
|
14
|
+
|
|
15
|
+
export function getPreparedTextCacheMissCount(): number {
|
|
16
|
+
return preparedTextCacheMissCount;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function prepareWithSegmentsCached(
|
|
20
|
+
text: string,
|
|
21
|
+
font: string,
|
|
22
|
+
options?: { whiteSpace?: "normal" | "pre-wrap" },
|
|
23
|
+
): PreparedTextWithSegments {
|
|
24
|
+
const key = `${options?.whiteSpace ?? "normal"}\u0000${font}\u0000${text}`;
|
|
25
|
+
const cached = preparedTextCache.get(key);
|
|
26
|
+
if (cached) return cached;
|
|
27
|
+
preparedTextCacheMissCount += 1;
|
|
28
|
+
const prepared = prepareWithSegments(text, font, options);
|
|
29
|
+
preparedTextCache.set(key, prepared);
|
|
30
|
+
return prepared;
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
export type TextLine = {
|
|
13
34
|
text: string;
|
|
14
35
|
width: number;
|
|
@@ -184,5 +205,6 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
184
205
|
}
|
|
185
206
|
|
|
186
207
|
export function clearPretextMeasurementCache(): void {
|
|
208
|
+
preparedTextCache.clear();
|
|
187
209
|
clearCache();
|
|
188
210
|
}
|
package/lib/relevance-status.ts
CHANGED
|
@@ -61,3 +61,76 @@ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
|
|
|
61
61
|
lastChange: null,
|
|
62
62
|
pending: { decisionsAwaitingReview: 1, staleSince: null },
|
|
63
63
|
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What the Newsroom assumes until a decision names the cyclotron: the defaults
|
|
67
|
+
* of the steering config's relevanceCyclotron block (src/papyrus_content/relevance_cyclotron.py).
|
|
68
|
+
*/
|
|
69
|
+
export const DEFAULT_RELEVANCE_CYCLOTRON = { id: "papyrus-relevance", labels: ["include", "exclude"] } as const;
|
|
70
|
+
|
|
71
|
+
/** The payload kind of a priming request; the sweep lists the cyclotron's payloads and filters on it. */
|
|
72
|
+
export const PRIMING_REQUEST_KIND = "cyclotron-priming-request";
|
|
73
|
+
|
|
74
|
+
/** The attachment role the sweep writes a request's outcome under. */
|
|
75
|
+
export const PRIMING_OUTCOME_ROLE = "priming_outcome";
|
|
76
|
+
|
|
77
|
+
/** The KnowledgeRawPayload an editor writes to ask the next sweep to prime the cyclotron with one URL. */
|
|
78
|
+
export function primingRequestPayloadId(cyclotronId: string, requestId: string): string {
|
|
79
|
+
return `knowledge-raw-payload-cyclotron-priming-request-${safeId(cyclotronId)}-${safeId(requestId)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type PrimingRequest = {
|
|
83
|
+
requestId: string;
|
|
84
|
+
url: string;
|
|
85
|
+
label: string;
|
|
86
|
+
explanation: string;
|
|
87
|
+
requestedBy: string;
|
|
88
|
+
requestedAt: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type PrimingOutcome =
|
|
92
|
+
| { state: "applied"; version: number; at: string; itemId?: string }
|
|
93
|
+
| { state: "failed"; message: string; at: string };
|
|
94
|
+
|
|
95
|
+
/** A saved request with the sweep's outcome, once there is one. */
|
|
96
|
+
export type PrimingRequestView = { request: PrimingRequest; outcome: PrimingOutcome | null };
|
|
97
|
+
|
|
98
|
+
export function buildPrimingRequest(
|
|
99
|
+
input: { url: string; label: string; explanation?: string },
|
|
100
|
+
requestedBy: string,
|
|
101
|
+
labels: readonly string[],
|
|
102
|
+
now: Date = new Date(),
|
|
103
|
+
requestId: string = `${now.getTime().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
104
|
+
): PrimingRequest {
|
|
105
|
+
let parsed: URL | null = null;
|
|
106
|
+
try {
|
|
107
|
+
parsed = new URL(input.url.trim());
|
|
108
|
+
} catch {
|
|
109
|
+
parsed = null;
|
|
110
|
+
}
|
|
111
|
+
if (!parsed || parsed.protocol !== "https:") throw new Error("Paste an https URL.");
|
|
112
|
+
if (!labels.includes(input.label)) throw new Error(`Choose a label: ${labels.join(" or ")}.`);
|
|
113
|
+
return {
|
|
114
|
+
requestId,
|
|
115
|
+
url: parsed.toString(),
|
|
116
|
+
label: input.label,
|
|
117
|
+
explanation: (input.explanation ?? "").trim(),
|
|
118
|
+
requestedBy,
|
|
119
|
+
requestedAt: now.toISOString(),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isPrimingOutcome(value: unknown): value is PrimingOutcome {
|
|
124
|
+
if (!value || typeof value !== "object") return false;
|
|
125
|
+
const outcome = value as Record<string, unknown>;
|
|
126
|
+
return (outcome.state === "applied" && typeof outcome.version === "number")
|
|
127
|
+
|| (outcome.state === "failed" && typeof outcome.message === "string");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** In words: waiting for the next sweep, applied as version N, or failed with the reason. */
|
|
131
|
+
export function primingStateText(view: PrimingRequestView): string {
|
|
132
|
+
if (!view.outcome) return "Waiting for the next sweep";
|
|
133
|
+
return view.outcome.state === "applied"
|
|
134
|
+
? `Applied as version ${view.outcome.version}`
|
|
135
|
+
: `Failed: ${view.outcome.message}`;
|
|
136
|
+
}
|
package/lib/site-brand.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isLocalPreviewEnabled } from "./local-preview";
|
|
1
2
|
import type { ComponentType } from "react";
|
|
2
3
|
import type { EditionPresentationFormat } from "./content-types";
|
|
3
4
|
import type { Article, ArticleVideoAsset } from "./articles";
|
|
@@ -5,6 +6,7 @@ import type { BlogPageBackgroundProps } from "../components/blog-page-background
|
|
|
5
6
|
import type { SiteBrandFont } from "./brand-fonts";
|
|
6
7
|
import type { PictogramFigureProps } from "../components/pictogram-figure";
|
|
7
8
|
import papyrusSite from "papyrus-site";
|
|
9
|
+
import { DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS, type ContentItemStageDefinition } from "./content-item-lifecycle";
|
|
8
10
|
import {
|
|
9
11
|
DEFAULT_THEME_PACK_TOKENS,
|
|
10
12
|
type HostingConfig,
|
|
@@ -92,6 +94,8 @@ export type SiteBrand = {
|
|
|
92
94
|
articleTitleSuffix: string;
|
|
93
95
|
/** Defaults to Story / Stories, the newsroom term. */
|
|
94
96
|
contentItemName?: ContentItemName;
|
|
97
|
+
/** The lifecycle stages in board order, last one being publication. Defaults to the standard newsroom lifecycle. */
|
|
98
|
+
contentItemStages?: readonly ContentItemStageDefinition[];
|
|
95
99
|
placeholderByline: string;
|
|
96
100
|
defaultPresentation: EditionPresentationFormat;
|
|
97
101
|
forcedPresentation?: EditionPresentationFormat;
|
|
@@ -181,11 +185,17 @@ const REFERENCE_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
|
|
|
181
185
|
* The Papyrus reference brand plus whatever the publication registered in its
|
|
182
186
|
* own `papyrus.config.ts` (resolved through the `papyrus-site` alias).
|
|
183
187
|
*/
|
|
184
|
-
const
|
|
188
|
+
const REGISTERED_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
|
|
185
189
|
...REFERENCE_SITE_BRANDS,
|
|
186
190
|
...Object.fromEntries((papyrusSite.brands ?? []).map((brand) => [brand.id, brand])),
|
|
187
191
|
};
|
|
188
192
|
|
|
193
|
+
const SITE_BRANDS: Record<SiteBrandId, SiteBrand> = isLocalPreviewEnabled()
|
|
194
|
+
? Object.fromEntries(
|
|
195
|
+
Object.entries(REGISTERED_SITE_BRANDS).map(([id, brand]) => [id, { ...brand, newsroomLayout: "organizer" as const }]),
|
|
196
|
+
)
|
|
197
|
+
: REGISTERED_SITE_BRANDS;
|
|
198
|
+
|
|
189
199
|
export function normalizeSiteBrandId(value: string | undefined | null): SiteBrandId | null {
|
|
190
200
|
if (!value) return null;
|
|
191
201
|
const trimmed = value.trim();
|
|
@@ -223,6 +233,10 @@ export function getContentItemName(brand: SiteBrand = SITE_BRAND): ContentItemNa
|
|
|
223
233
|
return brand.contentItemName ?? DEFAULT_CONTENT_ITEM_NAME;
|
|
224
234
|
}
|
|
225
235
|
|
|
236
|
+
export function getContentItemStageDefinitions(brand: SiteBrand = SITE_BRAND): readonly ContentItemStageDefinition[] {
|
|
237
|
+
return brand.contentItemStages?.length ? brand.contentItemStages : DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS;
|
|
238
|
+
}
|
|
239
|
+
|
|
226
240
|
/** Root-route config for a brand, defaulting to `{ kind: "reader" }`. */
|
|
227
241
|
export function getRootRoute(brand: SiteBrand = SITE_BRAND): RootRouteConfig {
|
|
228
242
|
return brand.rootRoute ?? { kind: "reader" };
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { type ArticlePublicationItem, getPublicationItemText } from "./publication-items";
|
|
2
|
+
import {
|
|
3
|
+
type LayoutCursor,
|
|
4
|
+
layoutTextLines,
|
|
5
|
+
type PreparedTextWithSegments,
|
|
6
|
+
prepareWithSegmentsCached,
|
|
7
|
+
type TextLine,
|
|
8
|
+
type TextObstacle,
|
|
9
|
+
} from "./pretext-layout";
|
|
10
|
+
import { reserveRhythmRows, type VerticalRhythm } from "./blog-rhythm";
|
|
11
|
+
import {
|
|
12
|
+
type FurnitureSufficiencyReport,
|
|
13
|
+
type LayoutConfig,
|
|
14
|
+
type SolvedChromeBox,
|
|
15
|
+
type SolvedFurniture,
|
|
16
|
+
} from "./layout-types";
|
|
17
|
+
import { SERIF_TEXT_FONT, clamp, getSpanWidth } from "./layout-config";
|
|
18
|
+
import { rectsOverlap } from "./furniture";
|
|
19
|
+
|
|
20
|
+
export function layoutTextColumns({
|
|
21
|
+
item,
|
|
22
|
+
cursor,
|
|
23
|
+
columnCount,
|
|
24
|
+
textHeight,
|
|
25
|
+
maximumTextHeightByColumn = [],
|
|
26
|
+
localConfig,
|
|
27
|
+
furniture,
|
|
28
|
+
chromeBoxes = [],
|
|
29
|
+
furnitureObstacleOffsetY = 0,
|
|
30
|
+
furnitureObstacleMarginBottom = 0,
|
|
31
|
+
minimumLineStartYByColumn = [],
|
|
32
|
+
}: {
|
|
33
|
+
item: ArticlePublicationItem;
|
|
34
|
+
cursor: LayoutCursor;
|
|
35
|
+
columnCount: number;
|
|
36
|
+
textHeight: number;
|
|
37
|
+
maximumTextHeightByColumn?: number[];
|
|
38
|
+
localConfig: LayoutConfig;
|
|
39
|
+
furniture: SolvedFurniture[];
|
|
40
|
+
chromeBoxes?: SolvedChromeBox[];
|
|
41
|
+
furnitureObstacleOffsetY?: number;
|
|
42
|
+
furnitureObstacleMarginBottom?: number;
|
|
43
|
+
minimumLineStartYByColumn?: number[];
|
|
44
|
+
}): { columns: TextLine[][]; cursor: LayoutCursor; hasMore: boolean } {
|
|
45
|
+
const columns: TextLine[][] = [];
|
|
46
|
+
const preparedText = getPrepared(item, localConfig.bodyFont);
|
|
47
|
+
const columnWidth = getSpanWidth(localConfig, 1);
|
|
48
|
+
let current = { ...cursor };
|
|
49
|
+
let hasMore = true;
|
|
50
|
+
|
|
51
|
+
for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
|
|
52
|
+
const result = layoutTextLines({
|
|
53
|
+
prepared: preparedText,
|
|
54
|
+
cursor: current,
|
|
55
|
+
maxHeight: maximumTextHeightByColumn[columnIndex] ?? textHeight,
|
|
56
|
+
maxWidth: columnWidth,
|
|
57
|
+
lineHeight: localConfig.lineHeight,
|
|
58
|
+
linePaintHeight: localConfig.linePaintHeight,
|
|
59
|
+
fontSize: localConfig.bodyFontSize,
|
|
60
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
61
|
+
obstacles: getColumnTextObstacles(
|
|
62
|
+
furniture,
|
|
63
|
+
columnIndex,
|
|
64
|
+
chromeBoxes,
|
|
65
|
+
furnitureObstacleOffsetY,
|
|
66
|
+
furnitureObstacleMarginBottom,
|
|
67
|
+
minimumLineStartYByColumn[columnIndex] ?? 0,
|
|
68
|
+
),
|
|
69
|
+
});
|
|
70
|
+
columns.push(result.lines);
|
|
71
|
+
current = result.cursor;
|
|
72
|
+
hasMore = result.hasMore;
|
|
73
|
+
if (!hasMore) {
|
|
74
|
+
while (columns.length < columnCount) columns.push([]);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { columns, cursor: current, hasMore };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getPrepared(article: ArticlePublicationItem, font: string): PreparedTextWithSegments {
|
|
83
|
+
return prepareWithSegmentsCached(getPublicationItemText(article), font, { whiteSpace: "pre-wrap" });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getLineLimitHeight(maxLines: number, lineHeight: number, linePaintHeight: number, rhythm: VerticalRhythm): number {
|
|
87
|
+
if (maxLines <= 0) return 0;
|
|
88
|
+
return reserveRhythmRows((maxLines - 1) * lineHeight + linePaintHeight, rhythm);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getColumnTextObstacles(
|
|
92
|
+
furniture: SolvedFurniture[],
|
|
93
|
+
columnIndex: number,
|
|
94
|
+
chromeBoxes: SolvedChromeBox[] = [],
|
|
95
|
+
furnitureOffsetY = 0,
|
|
96
|
+
furnitureMarginBottom = 0,
|
|
97
|
+
minimumLineStartY = 0,
|
|
98
|
+
): TextObstacle[] {
|
|
99
|
+
const topObstacle: TextObstacle[] = minimumLineStartY > 0
|
|
100
|
+
? [{ x: 0, y: 0, width: Number.POSITIVE_INFINITY, height: minimumLineStartY }]
|
|
101
|
+
: [];
|
|
102
|
+
const furnitureObstacles = furniture
|
|
103
|
+
.filter((item) => item.wrapsText && columnIndex >= item.columnStart && columnIndex < item.columnStart + item.columnSpan)
|
|
104
|
+
.map((item) => ({
|
|
105
|
+
x: 0,
|
|
106
|
+
y: item.y + furnitureOffsetY,
|
|
107
|
+
width: Number.POSITIVE_INFINITY,
|
|
108
|
+
height: item.height + getFurnitureObstacleMarginBottom(item, furnitureMarginBottom),
|
|
109
|
+
}));
|
|
110
|
+
const imageClearanceObstacles = furniture
|
|
111
|
+
.filter((item) => (
|
|
112
|
+
item.kind === "image" &&
|
|
113
|
+
item.wrapsText &&
|
|
114
|
+
furnitureMarginBottom > 0 &&
|
|
115
|
+
columnIndex >= item.columnStart &&
|
|
116
|
+
columnIndex < item.columnStart + item.columnSpan
|
|
117
|
+
))
|
|
118
|
+
.map((item) => ({
|
|
119
|
+
x: 0,
|
|
120
|
+
y: item.y + furnitureOffsetY + item.height,
|
|
121
|
+
width: Number.POSITIVE_INFINITY,
|
|
122
|
+
height: furnitureMarginBottom,
|
|
123
|
+
}));
|
|
124
|
+
const chromeObstacles = chromeBoxes
|
|
125
|
+
.filter((box) => columnIndex >= box.columnStart && columnIndex < box.columnStart + box.columnSpan)
|
|
126
|
+
.map((box) => ({
|
|
127
|
+
x: 0,
|
|
128
|
+
y: box.y,
|
|
129
|
+
width: Number.POSITIVE_INFINITY,
|
|
130
|
+
height: box.height,
|
|
131
|
+
}));
|
|
132
|
+
return [...topObstacle, ...furnitureObstacles, ...imageClearanceObstacles, ...chromeObstacles];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getFurnitureObstacleMarginBottom(item: SolvedFurniture, marginBottom: number): number {
|
|
136
|
+
return item.kind === "image" || item.kind === "pullQuote" ? marginBottom : 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getColumnWhitespace(columns: TextLine[][], textHeight: number, furniture: SolvedFurniture[]): number {
|
|
140
|
+
return columns.reduce(
|
|
141
|
+
(total, column, columnIndex) =>
|
|
142
|
+
total + Math.max(0, textHeight - getLinesHeight(column) - getColumnObstacleHeight(furniture, columnIndex, textHeight)),
|
|
143
|
+
0,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function getFurnitureSufficiencyReport({
|
|
148
|
+
columns,
|
|
149
|
+
textHeight,
|
|
150
|
+
furniture,
|
|
151
|
+
hasMore,
|
|
152
|
+
config,
|
|
153
|
+
}: {
|
|
154
|
+
columns: TextLine[][];
|
|
155
|
+
textHeight: number;
|
|
156
|
+
furniture: SolvedFurniture[];
|
|
157
|
+
hasMore: boolean;
|
|
158
|
+
config: LayoutConfig;
|
|
159
|
+
}): FurnitureSufficiencyReport {
|
|
160
|
+
const visibleRows = columns.reduce((total, column) => total + column.length, 0);
|
|
161
|
+
const furnitureRows = furniture.reduce(
|
|
162
|
+
(total, item) => total + getFurnitureRowBurden(item, config.rhythm.rowHeight),
|
|
163
|
+
0,
|
|
164
|
+
);
|
|
165
|
+
const accepted = (reason?: string): FurnitureSufficiencyReport => ({
|
|
166
|
+
accepted: !reason,
|
|
167
|
+
reason,
|
|
168
|
+
visibleRows,
|
|
169
|
+
furnitureRows,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (furniture.length === 0 || furnitureRows === 0) return accepted();
|
|
173
|
+
const overlapReason = getFurnitureOverlapReason(furniture);
|
|
174
|
+
if (overlapReason) return accepted(overlapReason);
|
|
175
|
+
const minimumReadableRows = columns.length * (furniture.some((item) => item.kind === "pullQuote") ? 6 : 3);
|
|
176
|
+
if (visibleRows * 4 < furnitureRows) {
|
|
177
|
+
return accepted("visible-copy-far-below-furniture-burden");
|
|
178
|
+
}
|
|
179
|
+
if (visibleRows < furnitureRows && visibleRows < minimumReadableRows) {
|
|
180
|
+
return accepted("visible-copy-below-furniture-burden");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const finalTextColumnIndex = columns.reduce((lastIndex, column, columnIndex) => (
|
|
184
|
+
column.length > 0 ? columnIndex : lastIndex
|
|
185
|
+
), -1);
|
|
186
|
+
const exhausted = !hasMore;
|
|
187
|
+
for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
|
|
188
|
+
const visibleColumnRows = columns[columnIndex].length;
|
|
189
|
+
const obstacleRows = Math.ceil(getColumnObstacleHeight(furniture, columnIndex, textHeight) / config.rhythm.rowHeight);
|
|
190
|
+
const availableRows = Math.floor(textHeight / config.rhythm.rowHeight) - obstacleRows;
|
|
191
|
+
const hasSubstantialFurniture = obstacleRows >= 6;
|
|
192
|
+
const isAfterExhaustedText = exhausted && columnIndex > finalTextColumnIndex;
|
|
193
|
+
if (!hasSubstantialFurniture && !isAfterExhaustedText && availableRows >= 3 && visibleColumnRows < 3) {
|
|
194
|
+
return accepted("open-column-below-minimum-copy");
|
|
195
|
+
}
|
|
196
|
+
if (!hasSubstantialFurniture && visibleColumnRows === 1 && columnIndex < finalTextColumnIndex) {
|
|
197
|
+
return accepted("non-final-column-single-copy-row");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return accepted();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getFurnitureRowBurden(item: SolvedFurniture, rowHeight: number): number {
|
|
205
|
+
if (item.kind !== "image" && item.kind !== "pullQuote") return 0;
|
|
206
|
+
return Math.ceil(item.height / rowHeight) * item.columnSpan;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getFurnitureOverlapReason(furniture: SolvedFurniture[]): string | undefined {
|
|
210
|
+
for (let firstIndex = 0; firstIndex < furniture.length; firstIndex += 1) {
|
|
211
|
+
for (let secondIndex = firstIndex + 1; secondIndex < furniture.length; secondIndex += 1) {
|
|
212
|
+
if (rectsOverlap(furniture[firstIndex], furniture[secondIndex])) return "furniture-overlap";
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function getColumnObstacleHeight(furniture: SolvedFurniture[], columnIndex: number, textHeight: number): number {
|
|
219
|
+
const intervals = furniture
|
|
220
|
+
.filter((item) => item.wrapsText && columnIndex >= item.columnStart && columnIndex < item.columnStart + item.columnSpan)
|
|
221
|
+
.map((item) => ({
|
|
222
|
+
start: clamp(item.y, 0, textHeight),
|
|
223
|
+
end: clamp(item.y + item.height, 0, textHeight),
|
|
224
|
+
}))
|
|
225
|
+
.filter((interval) => interval.end > interval.start)
|
|
226
|
+
.sort((a, b) => a.start - b.start);
|
|
227
|
+
|
|
228
|
+
const merged: Array<{ start: number; end: number }> = [];
|
|
229
|
+
for (const interval of intervals) {
|
|
230
|
+
const previous = merged[merged.length - 1];
|
|
231
|
+
if (previous && interval.start <= previous.end) previous.end = Math.max(previous.end, interval.end);
|
|
232
|
+
else merged.push({ ...interval });
|
|
233
|
+
}
|
|
234
|
+
return merged.reduce((total, interval) => total + interval.end - interval.start, 0);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getDeadColumnCount(columns: TextLine[][], textHeight: number, furniture: SolvedFurniture[]): number {
|
|
238
|
+
return columns.reduce((count, column, columnIndex) => {
|
|
239
|
+
if (column.length > 0) return count;
|
|
240
|
+
const obstacleHeight = getColumnObstacleHeight(furniture, columnIndex, textHeight);
|
|
241
|
+
return obstacleHeight >= Math.min(96, textHeight * 0.18) ? count : count + 1;
|
|
242
|
+
}, 0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function getLinesHeight(lines: TextLine[]): number {
|
|
246
|
+
return lines.length === 0 ? 0 : Math.max(...lines.map((line) => line.y + line.paintHeight));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function getFurnitureBottom(furniture: SolvedFurniture[]): number {
|
|
250
|
+
return furniture.reduce((bottom, item) => Math.max(bottom, item.y + item.height), 0);
|
|
251
|
+
}
|