@anthusai/papyrus 1.0.0-next.4 → 1.0.0-next.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/content-actions/handler.py +44 -2
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/site-backend-config.ts +6 -0
- package/amplify/site-backend.js +18 -0
- package/amplify/site-backend.ts +20 -0
- package/amplify/storage/resource.js +3 -0
- package/amplify/storage/resource.ts +3 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/globals.css +19 -0
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +26 -0
- package/components/article-video.tsx +112 -3
- package/components/markus-ir-preview.tsx +279 -0
- package/components/newsroom-access-gate.tsx +66 -0
- package/components/newsroom-article-editor.tsx +637 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/topic-steering-workspace.tsx +1 -59
- package/infra/amplify-app-shell.js +56 -1
- package/infra/amplify-app-shell.ts +79 -1
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +31 -1
- package/infra/site-config.ts +38 -1
- package/lib/content-actions-demo.ts +172 -0
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-nav.ts +2 -0
- package/lib/site-brand.ts +13 -0
- package/lib/staging-preview-object.ts +98 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +63 -0
- package/middleware.ts +14 -1
- package/package.json +16 -1
- package/routes.manifest.json +27 -1
- package/scripts/videoml/build-browser-bundle.mjs +22 -0
- package/scripts/videoml/build-preview-bundle.mjs +50 -0
- package/scripts/videoml/bundle-common.mjs +129 -0
- package/scripts/videoml/preview/preview-entry.tsx +381 -0
- package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
- package/scripts/videoml/preview/preview-timing.ts +42 -0
- package/scripts/videoml/preview.html +77 -0
- package/scripts/videoml/standard-entry.tsx +46 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { ContentActionFailure } from "./content-actions-client";
|
|
2
|
+
import type {
|
|
3
|
+
DeriveMarkusInput,
|
|
4
|
+
DeriveMarkusResult,
|
|
5
|
+
PublishItemResult,
|
|
6
|
+
SaveItemDraftInput,
|
|
7
|
+
SaveItemDraftResult,
|
|
8
|
+
UnpublishItemResult,
|
|
9
|
+
} from "./content-actions-client";
|
|
10
|
+
import {
|
|
11
|
+
createDemoArticleRecords,
|
|
12
|
+
demoContentHash,
|
|
13
|
+
type DemoArticleRecord,
|
|
14
|
+
} from "./newsroom-demo-articles";
|
|
15
|
+
import { sourceLineOffset, type ArticlesBackend, type EditableArticle, type NewsroomArticleRow } from "./newsroom-articles";
|
|
16
|
+
|
|
17
|
+
function titleFromYaml(frontMatterYaml: string | null, fallback: string): string {
|
|
18
|
+
for (const line of (frontMatterYaml ?? "").split("\n")) {
|
|
19
|
+
const match = /^title\s*:\s*(.*)$/.exec(line);
|
|
20
|
+
if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, "");
|
|
21
|
+
}
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function demoEnvelope(bodyMarkus: string) {
|
|
26
|
+
const children = bodyMarkus
|
|
27
|
+
.split(/\n{2,}/)
|
|
28
|
+
.map((block) => block.trim())
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map((block) => {
|
|
31
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(block);
|
|
32
|
+
if (heading) {
|
|
33
|
+
return { type: "heading", level: heading[1].length, inline: [{ type: "text", text: heading[2] }], line: null };
|
|
34
|
+
}
|
|
35
|
+
return { type: "paragraph", inline: [{ type: "text", text: block }], line: null };
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
schemaVersion: 1,
|
|
39
|
+
markus: { version: "demo", irSchemaVersion: 1 },
|
|
40
|
+
document: { type: "document", schema_version: 1, front_matter: {}, children },
|
|
41
|
+
papyrus: { images: {}, citations: {}, citationLists: {}, entries: {}, bibliography: [] },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createDemoArticlesBackend(): ArticlesBackend {
|
|
46
|
+
const records = new Map<string, DemoArticleRecord>(createDemoArticleRecords().map((record) => [record.id, record]));
|
|
47
|
+
let created = 0;
|
|
48
|
+
|
|
49
|
+
const requireRecord = (id: string): DemoArticleRecord => {
|
|
50
|
+
const record = records.get(id);
|
|
51
|
+
if (!record) throw new ContentActionFailure([{ code: "not-found", message: "That article was not found.", line: null }]);
|
|
52
|
+
return record;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
actions: {
|
|
57
|
+
async deriveMarkus(input: DeriveMarkusInput): Promise<DeriveMarkusResult> {
|
|
58
|
+
const lines = input.bodyMarkus.split("\n");
|
|
59
|
+
const invalidIndex = lines.findIndex((line) => line.includes(":::nope"));
|
|
60
|
+
if (invalidIndex >= 0) {
|
|
61
|
+
throw new ContentActionFailure([
|
|
62
|
+
{ code: "markus-validation", message: 'Unknown directive "nope".', line: invalidIndex + 1 + sourceLineOffset(input.frontMatterYaml) },
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
const envelope = demoEnvelope(input.bodyMarkus);
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
bodyIrBytes: JSON.stringify(envelope).length,
|
|
69
|
+
...(input.includeIr ? { bodyIr: envelope } : {}),
|
|
70
|
+
errors: [],
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
async saveItemDraft(input: SaveItemDraftInput): Promise<SaveItemDraftResult> {
|
|
74
|
+
const existing = input.id ? requireRecord(input.id) : null;
|
|
75
|
+
if (existing && input.expectedContentHash !== null && input.expectedContentHash !== existing.contentHash) {
|
|
76
|
+
throw new ContentActionFailure([
|
|
77
|
+
{ code: "conflict", message: "The item changed since it was loaded.", line: null },
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
if (existing && existing.everPublished && existing.slug !== input.slug) {
|
|
81
|
+
throw new ContentActionFailure([
|
|
82
|
+
{ code: "slug-locked", message: "The slug cannot change once the item has been published.", line: null },
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
const id = existing?.id ?? `demo-article-new-${(created += 1)}`;
|
|
86
|
+
const title = titleFromYaml(input.frontMatterYaml, input.slug);
|
|
87
|
+
const frontMatterYaml = input.frontMatterYaml ?? "";
|
|
88
|
+
const record: DemoArticleRecord = {
|
|
89
|
+
id,
|
|
90
|
+
type: input.type,
|
|
91
|
+
slug: input.slug,
|
|
92
|
+
section: input.section,
|
|
93
|
+
title,
|
|
94
|
+
status: existing?.status ?? "draft",
|
|
95
|
+
frontMatterYaml,
|
|
96
|
+
bodyMarkus: input.bodyMarkus,
|
|
97
|
+
contentHash: demoContentHash(frontMatterYaml, input.bodyMarkus),
|
|
98
|
+
publishedContentHash: existing?.publishedContentHash ?? null,
|
|
99
|
+
versionNumber: existing?.versionNumber ?? null,
|
|
100
|
+
everPublished: existing?.everPublished ?? false,
|
|
101
|
+
updatedAt: new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
records.set(id, record);
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
item: {
|
|
107
|
+
id,
|
|
108
|
+
contentHash: record.contentHash,
|
|
109
|
+
status: record.status,
|
|
110
|
+
slug: record.slug,
|
|
111
|
+
versionNumber: record.versionNumber,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
async publishItem(id: string): Promise<PublishItemResult> {
|
|
116
|
+
const record = requireRecord(id);
|
|
117
|
+
const changed = record.status !== "published" || record.publishedContentHash !== record.contentHash;
|
|
118
|
+
const versionNumber = changed ? (record.versionNumber ?? 0) + 1 : record.versionNumber;
|
|
119
|
+
records.set(id, {
|
|
120
|
+
...record,
|
|
121
|
+
status: "published",
|
|
122
|
+
everPublished: true,
|
|
123
|
+
publishedContentHash: record.contentHash,
|
|
124
|
+
versionNumber,
|
|
125
|
+
updatedAt: new Date().toISOString(),
|
|
126
|
+
});
|
|
127
|
+
return { ok: true, changed, publishedId: `published-${id}`, versionNumber };
|
|
128
|
+
},
|
|
129
|
+
async unpublishItem(id: string): Promise<UnpublishItemResult> {
|
|
130
|
+
const record = requireRecord(id);
|
|
131
|
+
const changed = record.status === "published";
|
|
132
|
+
records.set(id, {
|
|
133
|
+
...record,
|
|
134
|
+
status: "draft",
|
|
135
|
+
publishedContentHash: null,
|
|
136
|
+
updatedAt: new Date().toISOString(),
|
|
137
|
+
});
|
|
138
|
+
return { ok: true, changed };
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
async listRows(): Promise<NewsroomArticleRow[]> {
|
|
142
|
+
return [...records.values()]
|
|
143
|
+
.map((record) => ({
|
|
144
|
+
id: record.id,
|
|
145
|
+
type: record.type,
|
|
146
|
+
slug: record.slug,
|
|
147
|
+
section: record.section,
|
|
148
|
+
title: record.title || record.slug,
|
|
149
|
+
status: record.status,
|
|
150
|
+
pending: record.status === "published" && record.publishedContentHash !== record.contentHash,
|
|
151
|
+
updatedAt: record.updatedAt,
|
|
152
|
+
}))
|
|
153
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
154
|
+
},
|
|
155
|
+
async loadArticle(id: string): Promise<EditableArticle> {
|
|
156
|
+
const record = requireRecord(id);
|
|
157
|
+
return {
|
|
158
|
+
id: record.id,
|
|
159
|
+
type: record.type,
|
|
160
|
+
section: record.section,
|
|
161
|
+
slug: record.slug,
|
|
162
|
+
frontMatterYaml: record.frontMatterYaml,
|
|
163
|
+
bodyMarkus: record.bodyMarkus,
|
|
164
|
+
contentHash: record.contentHash,
|
|
165
|
+
status: record.status,
|
|
166
|
+
versionNumber: record.versionNumber,
|
|
167
|
+
aliases: [],
|
|
168
|
+
everPublished: record.everPublished,
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { generateClient } from "aws-amplify/data";
|
|
2
|
+
import type { Schema } from "../amplify/data/resource";
|
|
3
|
+
import {
|
|
4
|
+
deriveMarkus,
|
|
5
|
+
publishItem,
|
|
6
|
+
saveItemDraft,
|
|
7
|
+
unpublishItem,
|
|
8
|
+
type ContentActionError,
|
|
9
|
+
} from "./content-actions-client";
|
|
10
|
+
import { createDemoArticlesBackend } from "./content-actions-demo";
|
|
11
|
+
|
|
12
|
+
export type ArticleStatus = "draft" | "published";
|
|
13
|
+
export type ArticleType = "article" | "page";
|
|
14
|
+
|
|
15
|
+
export const ARTICLE_TYPES: ArticleType[] = ["article", "page"];
|
|
16
|
+
|
|
17
|
+
export type NewsroomArticleRow = {
|
|
18
|
+
id: string;
|
|
19
|
+
type: string;
|
|
20
|
+
slug: string;
|
|
21
|
+
section: string | null;
|
|
22
|
+
title: string;
|
|
23
|
+
status: ArticleStatus;
|
|
24
|
+
pending: boolean;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ArticleItemSummary = {
|
|
29
|
+
id: string;
|
|
30
|
+
lineageId?: string | null;
|
|
31
|
+
type: string;
|
|
32
|
+
slug: string;
|
|
33
|
+
section?: string | null;
|
|
34
|
+
title?: string | null;
|
|
35
|
+
status: string;
|
|
36
|
+
contentHash?: string | null;
|
|
37
|
+
updatedAt?: string | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type PublishedItemSummary = {
|
|
41
|
+
id: string;
|
|
42
|
+
itemLineageId?: string | null;
|
|
43
|
+
sourceItemId?: string | null;
|
|
44
|
+
metadata?: unknown;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type NewsroomArticleEditorState = {
|
|
48
|
+
id: string | null;
|
|
49
|
+
type: string;
|
|
50
|
+
section: string;
|
|
51
|
+
slug: string;
|
|
52
|
+
frontMatterYaml: string;
|
|
53
|
+
bodyMarkus: string;
|
|
54
|
+
contentHash: string | null;
|
|
55
|
+
status: ArticleStatus;
|
|
56
|
+
versionNumber: number | null;
|
|
57
|
+
dirty: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type EditableArticle = {
|
|
61
|
+
id: string;
|
|
62
|
+
type: string;
|
|
63
|
+
section: string | null;
|
|
64
|
+
slug: string;
|
|
65
|
+
frontMatterYaml: string;
|
|
66
|
+
bodyMarkus: string;
|
|
67
|
+
contentHash: string | null;
|
|
68
|
+
status: ArticleStatus;
|
|
69
|
+
versionNumber: number | null;
|
|
70
|
+
aliases: string[];
|
|
71
|
+
everPublished: boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type ArticlesBackend = {
|
|
75
|
+
actions: {
|
|
76
|
+
deriveMarkus: typeof deriveMarkus;
|
|
77
|
+
saveItemDraft: typeof saveItemDraft;
|
|
78
|
+
publishItem: typeof publishItem;
|
|
79
|
+
unpublishItem: typeof unpublishItem;
|
|
80
|
+
};
|
|
81
|
+
listRows: () => Promise<NewsroomArticleRow[]>;
|
|
82
|
+
loadArticle: (id: string) => Promise<EditableArticle>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function slugify(value: string): string {
|
|
86
|
+
return value
|
|
87
|
+
.normalize("NFKD")
|
|
88
|
+
.replace(/[^\x00-\x7F]/g, "")
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
91
|
+
.replace(/^-+|-+$/g, "");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function suggestSlug(frontMatterYaml: string): string {
|
|
95
|
+
for (const line of frontMatterYaml.split("\n")) {
|
|
96
|
+
const match = /^title\s*:\s*(.*)$/.exec(line);
|
|
97
|
+
if (!match) continue;
|
|
98
|
+
let value = match[1].trim();
|
|
99
|
+
if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.endsWith(value[0])) {
|
|
100
|
+
value = value.slice(1, -1);
|
|
101
|
+
}
|
|
102
|
+
return slugify(value);
|
|
103
|
+
}
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function errorLine(errors: ContentActionError[]): number | null {
|
|
108
|
+
for (const error of errors) {
|
|
109
|
+
if (typeof error.line === "number") return error.line;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function sourceLineOffset(frontMatterYaml: string | null): number {
|
|
115
|
+
const yamlText = (frontMatterYaml ?? "").replace(/^\n+|\n+$/g, "");
|
|
116
|
+
if (!yamlText.trim()) return 0;
|
|
117
|
+
return yamlText.split("\n").length + 2;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type ErrorLocation = { field: "frontMatterYaml" | "bodyMarkus"; line: number | null };
|
|
121
|
+
|
|
122
|
+
export function locateError(error: ContentActionError, frontMatterYaml: string | null): ErrorLocation {
|
|
123
|
+
if (error.code === "front-matter") return { field: "frontMatterYaml", line: null };
|
|
124
|
+
if (error.line === null) return { field: "bodyMarkus", line: null };
|
|
125
|
+
const bodyLine = error.line - sourceLineOffset(frontMatterYaml);
|
|
126
|
+
if (bodyLine >= 1) return { field: "bodyMarkus", line: bodyLine };
|
|
127
|
+
return { field: "frontMatterYaml", line: Math.max(error.line - 1, 1) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function emptyArticle(): NewsroomArticleEditorState {
|
|
131
|
+
return {
|
|
132
|
+
id: null,
|
|
133
|
+
type: "article",
|
|
134
|
+
section: "",
|
|
135
|
+
slug: "",
|
|
136
|
+
frontMatterYaml: "title: \n",
|
|
137
|
+
bodyMarkus: "",
|
|
138
|
+
contentHash: null,
|
|
139
|
+
status: "draft",
|
|
140
|
+
versionNumber: null,
|
|
141
|
+
dirty: false,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseJsonObject(value: unknown): Record<string, unknown> {
|
|
146
|
+
if (typeof value === "string") {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(value);
|
|
149
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
|
|
150
|
+
} catch {
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function hasPendingChanges(item: ArticleItemSummary, published: PublishedItemSummary | null): boolean {
|
|
158
|
+
if (!published) return false;
|
|
159
|
+
return item.contentHash !== parseJsonObject(published.metadata).sourceContentHash;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function articleStatusLabel(item: ArticleItemSummary, published: PublishedItemSummary | null): string {
|
|
163
|
+
if (item.status !== "published") return "Draft";
|
|
164
|
+
return hasPendingChanges(item, published) ? "Published, unpublished changes" : "Published";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function rowFromItem(item: ArticleItemSummary, published: PublishedItemSummary | null): NewsroomArticleRow {
|
|
168
|
+
const status: ArticleStatus = item.status === "published" ? "published" : "draft";
|
|
169
|
+
return {
|
|
170
|
+
id: item.id,
|
|
171
|
+
type: item.type,
|
|
172
|
+
slug: item.slug,
|
|
173
|
+
section: item.section ?? null,
|
|
174
|
+
title: item.title || item.slug,
|
|
175
|
+
status,
|
|
176
|
+
pending: status === "published" && hasPendingChanges(item, published),
|
|
177
|
+
updatedAt: item.updatedAt ?? "",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function sortRowsByUpdatedAtDescending(rows: NewsroomArticleRow[]): NewsroomArticleRow[] {
|
|
182
|
+
return [...rows].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function rowStatusLabel(row: NewsroomArticleRow): string {
|
|
186
|
+
if (row.status === "draft") return "Draft";
|
|
187
|
+
return row.pending ? "Published, unpublished changes" : "Published";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
type ListPage<T> = { data?: (T | null)[] | null; nextToken?: string | null; errors?: { message?: string }[] | null };
|
|
191
|
+
type ListOptions = { authMode: "userPool"; nextToken?: string | null; limit?: number };
|
|
192
|
+
|
|
193
|
+
export type ArticlesDataClient = {
|
|
194
|
+
models: {
|
|
195
|
+
Item: {
|
|
196
|
+
listItemsByTypeStatusAndPublishedAt: (
|
|
197
|
+
input: { typeStatus: string },
|
|
198
|
+
options: ListOptions,
|
|
199
|
+
) => Promise<ListPage<ArticleItemSummary>>;
|
|
200
|
+
get: (
|
|
201
|
+
input: { id: string },
|
|
202
|
+
options: { authMode: "userPool" },
|
|
203
|
+
) => Promise<{ data?: Record<string, unknown> | null; errors?: { message?: string }[] | null }>;
|
|
204
|
+
};
|
|
205
|
+
PublishedItem: {
|
|
206
|
+
listPublishedItemsByTypeStatusAndPublishedAt: (
|
|
207
|
+
input: { typeStatus: string },
|
|
208
|
+
options: ListOptions,
|
|
209
|
+
) => Promise<ListPage<PublishedItemSummary>>;
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const NEWSROOM_ARTICLE_TYPE_STATUSES = ["article#draft", "article#published", "page#draft", "page#published"];
|
|
215
|
+
const NEWSROOM_PUBLISHED_TYPE_STATUSES = ["article#published", "page#published"];
|
|
216
|
+
|
|
217
|
+
async function collectPages<T>(
|
|
218
|
+
fetchPage: (options: ListOptions) => Promise<ListPage<T>>,
|
|
219
|
+
): Promise<T[]> {
|
|
220
|
+
const collected: T[] = [];
|
|
221
|
+
let nextToken: string | null | undefined = null;
|
|
222
|
+
do {
|
|
223
|
+
const page: ListPage<T> = await fetchPage({ authMode: "userPool", nextToken, limit: 500 });
|
|
224
|
+
if (page.errors && page.errors.length > 0) {
|
|
225
|
+
throw new Error(page.errors.map((error) => error.message ?? "GraphQL error").join("; "));
|
|
226
|
+
}
|
|
227
|
+
for (const row of page.data ?? []) {
|
|
228
|
+
if (row) collected.push(row);
|
|
229
|
+
}
|
|
230
|
+
nextToken = page.nextToken;
|
|
231
|
+
} while (nextToken);
|
|
232
|
+
return collected;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function loadArticleRows(client: ArticlesDataClient): Promise<NewsroomArticleRow[]> {
|
|
236
|
+
const itemGroups = await Promise.all(
|
|
237
|
+
NEWSROOM_ARTICLE_TYPE_STATUSES.map((typeStatus) =>
|
|
238
|
+
collectPages((options) => client.models.Item.listItemsByTypeStatusAndPublishedAt({ typeStatus }, options)),
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
const publishedGroups = await Promise.all(
|
|
242
|
+
NEWSROOM_PUBLISHED_TYPE_STATUSES.map((typeStatus) =>
|
|
243
|
+
collectPages((options) =>
|
|
244
|
+
client.models.PublishedItem.listPublishedItemsByTypeStatusAndPublishedAt({ typeStatus }, options),
|
|
245
|
+
),
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
const publishedByLineage = new Map<string, PublishedItemSummary>();
|
|
249
|
+
for (const published of publishedGroups.flat()) {
|
|
250
|
+
const lineage = published.itemLineageId ?? published.sourceItemId;
|
|
251
|
+
if (lineage) publishedByLineage.set(lineage, published);
|
|
252
|
+
}
|
|
253
|
+
const rows = itemGroups
|
|
254
|
+
.flat()
|
|
255
|
+
.map((item) => rowFromItem(item, publishedByLineage.get(item.lineageId ?? item.id) ?? null));
|
|
256
|
+
return sortRowsByUpdatedAtDescending(rows);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function loadEditableArticle(client: ArticlesDataClient, id: string): Promise<EditableArticle> {
|
|
260
|
+
const response = await client.models.Item.get({ id }, { authMode: "userPool" });
|
|
261
|
+
if (response.errors && response.errors.length > 0) {
|
|
262
|
+
throw new Error(response.errors.map((error) => error.message ?? "GraphQL error").join("; "));
|
|
263
|
+
}
|
|
264
|
+
const record = response.data;
|
|
265
|
+
if (!record) throw new Error("That article was not found.");
|
|
266
|
+
const metadata = parseJsonObject(record.metadata);
|
|
267
|
+
const status: ArticleStatus = record.status === "published" ? "published" : "draft";
|
|
268
|
+
return {
|
|
269
|
+
id: String(record.id),
|
|
270
|
+
type: String(record.type),
|
|
271
|
+
section: typeof record.section === "string" ? record.section : null,
|
|
272
|
+
slug: String(record.slug),
|
|
273
|
+
frontMatterYaml: typeof metadata.frontMatterYaml === "string" ? metadata.frontMatterYaml : "",
|
|
274
|
+
bodyMarkus: typeof record.bodyMarkus === "string" ? record.bodyMarkus : "",
|
|
275
|
+
contentHash: typeof record.contentHash === "string" ? record.contentHash : null,
|
|
276
|
+
status,
|
|
277
|
+
versionNumber: typeof record.versionNumber === "number" ? record.versionNumber : null,
|
|
278
|
+
aliases: Array.isArray(record.aliases) ? record.aliases.map(String) : [],
|
|
279
|
+
everPublished: status === "published",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function createLiveArticlesBackend(): ArticlesBackend {
|
|
284
|
+
let client: ArticlesDataClient | null = null;
|
|
285
|
+
const dataClient = () => {
|
|
286
|
+
if (!client) client = generateClient<Schema>() as unknown as ArticlesDataClient;
|
|
287
|
+
return client;
|
|
288
|
+
};
|
|
289
|
+
return {
|
|
290
|
+
actions: { deriveMarkus, saveItemDraft, publishItem, unpublishItem },
|
|
291
|
+
listRows: () => loadArticleRows(dataClient()),
|
|
292
|
+
loadArticle: (id) => loadEditableArticle(dataClient(), id),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let liveBackend: ArticlesBackend | null = null;
|
|
297
|
+
let demoBackend: ArticlesBackend | null = null;
|
|
298
|
+
|
|
299
|
+
export function resolveArticlesBackend(demo: boolean): ArticlesBackend {
|
|
300
|
+
if (demo) {
|
|
301
|
+
demoBackend ??= createDemoArticlesBackend();
|
|
302
|
+
return demoBackend;
|
|
303
|
+
}
|
|
304
|
+
liveBackend ??= createLiveArticlesBackend();
|
|
305
|
+
return liveBackend;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function stagingPreviewUrl(
|
|
309
|
+
stagingBaseUrl: string | undefined,
|
|
310
|
+
type: string,
|
|
311
|
+
slug: string,
|
|
312
|
+
staticSite: boolean,
|
|
313
|
+
): string | null {
|
|
314
|
+
if (!stagingBaseUrl || !slug || type !== "article") return null;
|
|
315
|
+
const base = stagingBaseUrl.replace(/\/+$/, "");
|
|
316
|
+
return staticSite ? `${base}/articles/${slug}.html` : `${base}/articles/${slug}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function uniqueSections(rows: NewsroomArticleRow[]): string[] {
|
|
320
|
+
const sections = new Set<string>();
|
|
321
|
+
for (const row of rows) {
|
|
322
|
+
if (row.section) sections.add(row.section);
|
|
323
|
+
}
|
|
324
|
+
return [...sections].sort();
|
|
325
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export type DemoArticleRecord = {
|
|
2
|
+
id: string;
|
|
3
|
+
type: string;
|
|
4
|
+
slug: string;
|
|
5
|
+
section: string | null;
|
|
6
|
+
title: string;
|
|
7
|
+
status: "draft" | "published";
|
|
8
|
+
frontMatterYaml: string;
|
|
9
|
+
bodyMarkus: string;
|
|
10
|
+
contentHash: string;
|
|
11
|
+
publishedContentHash: string | null;
|
|
12
|
+
versionNumber: number | null;
|
|
13
|
+
everPublished: boolean;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function demoContentHash(frontMatterYaml: string, bodyMarkus: string): string {
|
|
18
|
+
let hash = 5381;
|
|
19
|
+
const text = `${frontMatterYaml}\u0000${bodyMarkus}`;
|
|
20
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
21
|
+
hash = ((hash << 5) + hash + text.charCodeAt(index)) >>> 0;
|
|
22
|
+
}
|
|
23
|
+
return `demo:${hash.toString(16)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function demoRecord(
|
|
27
|
+
fields: Pick<DemoArticleRecord, "id" | "slug" | "section" | "title" | "bodyMarkus" | "updatedAt"> & {
|
|
28
|
+
type?: string;
|
|
29
|
+
status: "draft" | "published";
|
|
30
|
+
publishedBody?: string;
|
|
31
|
+
versionNumber: number | null;
|
|
32
|
+
},
|
|
33
|
+
): DemoArticleRecord {
|
|
34
|
+
const frontMatterYaml = `title: ${fields.title}\n`;
|
|
35
|
+
const contentHash = demoContentHash(frontMatterYaml, fields.bodyMarkus);
|
|
36
|
+
const publishedContentHash = fields.status === "published"
|
|
37
|
+
? demoContentHash(frontMatterYaml, fields.publishedBody ?? fields.bodyMarkus)
|
|
38
|
+
: null;
|
|
39
|
+
return {
|
|
40
|
+
id: fields.id,
|
|
41
|
+
type: fields.type ?? "article",
|
|
42
|
+
slug: fields.slug,
|
|
43
|
+
section: fields.section,
|
|
44
|
+
title: fields.title,
|
|
45
|
+
status: fields.status,
|
|
46
|
+
frontMatterYaml,
|
|
47
|
+
bodyMarkus: fields.bodyMarkus,
|
|
48
|
+
contentHash,
|
|
49
|
+
publishedContentHash,
|
|
50
|
+
versionNumber: fields.versionNumber,
|
|
51
|
+
everPublished: fields.status === "published",
|
|
52
|
+
updatedAt: fields.updatedAt,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createDemoArticleRecords(): DemoArticleRecord[] {
|
|
57
|
+
return [
|
|
58
|
+
demoRecord({
|
|
59
|
+
id: "demo-article-changed",
|
|
60
|
+
slug: "forty-billion-dollars",
|
|
61
|
+
section: "business",
|
|
62
|
+
title: "Forty Billion Dollars",
|
|
63
|
+
bodyMarkus: "The raise doubled again this quarter.\n\nAnalysts expect the pace to slow.\n",
|
|
64
|
+
publishedBody: "The raise doubled this quarter.\n",
|
|
65
|
+
status: "published",
|
|
66
|
+
versionNumber: 2,
|
|
67
|
+
updatedAt: "2026-10-05T16:00:00.000Z",
|
|
68
|
+
}),
|
|
69
|
+
demoRecord({
|
|
70
|
+
id: "demo-article-published",
|
|
71
|
+
slug: "harbor-cranes-return",
|
|
72
|
+
section: "local",
|
|
73
|
+
title: "Harbor Cranes Return",
|
|
74
|
+
bodyMarkus: "# Harbor Cranes Return\n\nThe cranes were lifted back into place on Monday.\n",
|
|
75
|
+
status: "published",
|
|
76
|
+
versionNumber: 1,
|
|
77
|
+
updatedAt: "2026-10-04T12:00:00.000Z",
|
|
78
|
+
}),
|
|
79
|
+
demoRecord({
|
|
80
|
+
id: "demo-article-draft",
|
|
81
|
+
slug: "council-budget-notes",
|
|
82
|
+
section: "politics",
|
|
83
|
+
title: "Council Budget Notes",
|
|
84
|
+
bodyMarkus: "Notes from the budget session.\n",
|
|
85
|
+
status: "draft",
|
|
86
|
+
versionNumber: null,
|
|
87
|
+
updatedAt: "2026-10-03T09:00:00.000Z",
|
|
88
|
+
}),
|
|
89
|
+
];
|
|
90
|
+
}
|
package/lib/newsroom-nav.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { newsroomHref, toPublicNewsroomPath } from "./newsroom-base-path";
|
|
|
2
2
|
|
|
3
3
|
export type NewsroomNavTabId =
|
|
4
4
|
| "overview"
|
|
5
|
+
| "articles"
|
|
5
6
|
| "messages"
|
|
6
7
|
| "insights"
|
|
7
8
|
| "assignments"
|
|
@@ -22,6 +23,7 @@ export type NewsroomNavItem = {
|
|
|
22
23
|
|
|
23
24
|
export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
|
|
24
25
|
{ id: "overview", label: "Overview", detail: "Desk home", href: newsroomHref(), mobilePrimary: true },
|
|
26
|
+
{ id: "articles", label: "Articles", detail: "Drafts & published", href: newsroomHref("articles"), mobilePrimary: true },
|
|
25
27
|
{ id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments"), mobilePrimary: true },
|
|
26
28
|
{ id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references"), mobilePrimary: true },
|
|
27
29
|
{ id: "messages", label: "Messages", detail: "Forum", href: newsroomHref("messages") },
|
package/lib/site-brand.ts
CHANGED
|
@@ -34,6 +34,18 @@ export type SiteBrandDemoEdition = {
|
|
|
34
34
|
editionVideo?: ArticleVideoAsset;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Video slot: how a publication supplies its own VideoML scene components.
|
|
39
|
+
* Paths are relative to the publication root.
|
|
40
|
+
*/
|
|
41
|
+
export type SiteBrandVideo = {
|
|
42
|
+
/** TSX module that registers the brand's `@videoml` scene components via `window.Babulus.registerComponent`. */
|
|
43
|
+
bundleEntry: string;
|
|
44
|
+
/** DSL element aliases for the browser preview, e.g. `{ "quote-card": "acme-quote-card" }`. */
|
|
45
|
+
sceneComponents?: Record<string, string>;
|
|
46
|
+
rhythm?: Record<string, unknown>;
|
|
47
|
+
};
|
|
48
|
+
|
|
37
49
|
export type SiteBrand = {
|
|
38
50
|
id: SiteBrandId;
|
|
39
51
|
appTitle: string;
|
|
@@ -76,6 +88,7 @@ export type SiteBrand = {
|
|
|
76
88
|
publicationName: string;
|
|
77
89
|
components?: SiteBrandComponents;
|
|
78
90
|
demoEdition?: SiteBrandDemoEdition;
|
|
91
|
+
video?: SiteBrandVideo;
|
|
79
92
|
};
|
|
80
93
|
|
|
81
94
|
const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
|