@anthusai/papyrus 1.16.0 → 1.18.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/data/resource.js +13 -0
- package/amplify/data/resource.ts +14 -0
- package/amplify/data/schema.js +13 -0
- package/amplify/data/schema.ts +14 -0
- package/amplify/functions/assignment-action/handler.ts +24 -163
- package/amplify/functions/console-chat-responder/src/main.rs +1 -1
- package/amplify/functions/shared/editorial-context.ts +160 -0
- package/amplify/functions/shared/model-payloads.ts +9 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/page.tsx +3 -0
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
- package/app/api/archive/editions/route.ts +2 -0
- package/app/archive/page.tsx +3 -0
- package/app/page.tsx +30 -1
- package/bin/papyrus-infra.mjs +42 -4
- package/components/item-stream-view.tsx +63 -0
- package/components/newsroom-assignments-view.tsx +1 -1
- package/components/topic-steering-workspace.tsx +2 -2
- package/infra/build-specs.js +3 -0
- package/infra/build-specs.ts +4 -0
- package/infra/site-config.js +5 -1
- package/infra/site-config.ts +6 -1
- package/lib/category-repository.ts +1 -1
- package/lib/item-stream.ts +47 -0
- package/lib/newsroom-assignments.ts +1 -1
- package/lib/newsroom-demo-dashboard.ts +1 -1
- package/lib/reader-static-params.ts +2 -1
- package/lib/reporting-story-budget.ts +2 -2
- package/lib/site-brand.ts +11 -0
- package/lib/site-env.ts +5 -0
- package/package.json +3 -1
- package/routes.manifest.json +1 -1
package/amplify/data/resource.js
CHANGED
|
@@ -808,6 +808,7 @@ const schema = a.schema({
|
|
|
808
808
|
categoryLineageId: a.id(),
|
|
809
809
|
}),
|
|
810
810
|
AssignmentContext: a.customType({
|
|
811
|
+
editorialContext: a.json().required(),
|
|
811
812
|
assignment: a.ref("AssignmentSummary"),
|
|
812
813
|
doctrine: a.ref("AssignmentDoctrineSummary").array().required(),
|
|
813
814
|
targets: a.ref("AssignmentTargetSummary").array().required(),
|
|
@@ -834,6 +835,18 @@ const schema = a.schema({
|
|
|
834
835
|
.returns(a.ref("AssignmentContext").array().required())
|
|
835
836
|
.authorization((allow) => [allow.groups(categoryWriteGroups)])
|
|
836
837
|
.handler(a.handler.function(assignmentAction)),
|
|
838
|
+
getEditorialContext: a
|
|
839
|
+
.query()
|
|
840
|
+
.arguments({
|
|
841
|
+
publicationKey: a.string().required(),
|
|
842
|
+
sectionId: a.id(),
|
|
843
|
+
storyLineageId: a.id(),
|
|
844
|
+
assignmentId: a.id(),
|
|
845
|
+
maxCharacters: a.integer(),
|
|
846
|
+
})
|
|
847
|
+
.returns(a.json().required())
|
|
848
|
+
.authorization((allow) => [allow.groups(categoryWriteGroups)])
|
|
849
|
+
.handler(a.handler.function(assignmentAction)),
|
|
837
850
|
getAssignmentContext: a
|
|
838
851
|
.query()
|
|
839
852
|
.arguments({
|
package/amplify/data/resource.ts
CHANGED
|
@@ -878,6 +878,7 @@ const schema = a.schema({
|
|
|
878
878
|
}),
|
|
879
879
|
|
|
880
880
|
AssignmentContext: a.customType({
|
|
881
|
+
editorialContext: a.json().required(),
|
|
881
882
|
assignment: a.ref("AssignmentSummary"),
|
|
882
883
|
doctrine: a.ref("AssignmentDoctrineSummary").array().required(),
|
|
883
884
|
targets: a.ref("AssignmentTargetSummary").array().required(),
|
|
@@ -907,6 +908,19 @@ const schema = a.schema({
|
|
|
907
908
|
.authorization((allow) => [allow.groups(categoryWriteGroups)])
|
|
908
909
|
.handler(a.handler.function(assignmentAction)),
|
|
909
910
|
|
|
911
|
+
getEditorialContext: a
|
|
912
|
+
.query()
|
|
913
|
+
.arguments({
|
|
914
|
+
publicationKey: a.string().required(),
|
|
915
|
+
sectionId: a.id(),
|
|
916
|
+
storyLineageId: a.id(),
|
|
917
|
+
assignmentId: a.id(),
|
|
918
|
+
maxCharacters: a.integer(),
|
|
919
|
+
})
|
|
920
|
+
.returns(a.json().required())
|
|
921
|
+
.authorization((allow) => [allow.groups(categoryWriteGroups)])
|
|
922
|
+
.handler(a.handler.function(assignmentAction)),
|
|
923
|
+
|
|
910
924
|
getAssignmentContext: a
|
|
911
925
|
.query()
|
|
912
926
|
.arguments({
|
package/amplify/data/schema.js
CHANGED
|
@@ -715,6 +715,7 @@ const baseDefinitions = {
|
|
|
715
715
|
categoryLineageId: a.id(),
|
|
716
716
|
}),
|
|
717
717
|
AssignmentContext: a.customType({
|
|
718
|
+
editorialContext: a.json().required(),
|
|
718
719
|
assignment: a.ref("AssignmentSummary"),
|
|
719
720
|
doctrine: a.ref("AssignmentDoctrineSummary").array().required(),
|
|
720
721
|
targets: a.ref("AssignmentTargetSummary").array().required(),
|
|
@@ -741,6 +742,18 @@ const baseDefinitions = {
|
|
|
741
742
|
.returns(a.ref("AssignmentContext").array().required())
|
|
742
743
|
.authorization((allow) => [allow.groups(categoryWriteGroups)])
|
|
743
744
|
.handler(a.handler.function(assignmentAction)),
|
|
745
|
+
getEditorialContext: a
|
|
746
|
+
.query()
|
|
747
|
+
.arguments({
|
|
748
|
+
publicationKey: a.string().required(),
|
|
749
|
+
sectionId: a.id(),
|
|
750
|
+
storyLineageId: a.id(),
|
|
751
|
+
assignmentId: a.id(),
|
|
752
|
+
maxCharacters: a.integer(),
|
|
753
|
+
})
|
|
754
|
+
.returns(a.json().required())
|
|
755
|
+
.authorization((allow) => [allow.groups(categoryWriteGroups)])
|
|
756
|
+
.handler(a.handler.function(assignmentAction)),
|
|
744
757
|
getAssignmentContext: a
|
|
745
758
|
.query()
|
|
746
759
|
.arguments({
|
package/amplify/data/schema.ts
CHANGED
|
@@ -779,6 +779,7 @@ const baseDefinitions = {
|
|
|
779
779
|
}),
|
|
780
780
|
|
|
781
781
|
AssignmentContext: a.customType({
|
|
782
|
+
editorialContext: a.json().required(),
|
|
782
783
|
assignment: a.ref("AssignmentSummary"),
|
|
783
784
|
doctrine: a.ref("AssignmentDoctrineSummary").array().required(),
|
|
784
785
|
targets: a.ref("AssignmentTargetSummary").array().required(),
|
|
@@ -808,6 +809,19 @@ const baseDefinitions = {
|
|
|
808
809
|
.authorization((allow) => [allow.groups(categoryWriteGroups)])
|
|
809
810
|
.handler(a.handler.function(assignmentAction)),
|
|
810
811
|
|
|
812
|
+
getEditorialContext: a
|
|
813
|
+
.query()
|
|
814
|
+
.arguments({
|
|
815
|
+
publicationKey: a.string().required(),
|
|
816
|
+
sectionId: a.id(),
|
|
817
|
+
storyLineageId: a.id(),
|
|
818
|
+
assignmentId: a.id(),
|
|
819
|
+
maxCharacters: a.integer(),
|
|
820
|
+
})
|
|
821
|
+
.returns(a.json().required())
|
|
822
|
+
.authorization((allow) => [allow.groups(categoryWriteGroups)])
|
|
823
|
+
.handler(a.handler.function(assignmentAction)),
|
|
824
|
+
|
|
811
825
|
getAssignmentContext: a
|
|
812
826
|
.query()
|
|
813
827
|
.arguments({
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { assignmentDoctrine, resolveEditorialContext, type EditorialClient } from "../shared/editorial-context";
|
|
2
|
+
import { tenantIdFromEnvironment } from "../../../lib/tenant-keys";
|
|
1
3
|
import { randomUUID } from "node:crypto";
|
|
2
4
|
import { spawnSync } from "node:child_process";
|
|
3
5
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -7,7 +9,7 @@ import { getAmplifyDataClientConfig } from "@aws-amplify/backend/function/runtim
|
|
|
7
9
|
import { Amplify } from "aws-amplify";
|
|
8
10
|
import { generateClient } from "aws-amplify/data";
|
|
9
11
|
import type { Schema } from "../../data/resource";
|
|
10
|
-
import { putJsonModelPayload, readJsonModelPayload } from "../shared/model-payloads";
|
|
12
|
+
import { putJsonModelPayload, readJsonModelPayload, readEditorialAttachment } from "../shared/model-payloads";
|
|
11
13
|
|
|
12
14
|
type AssignmentHandler =
|
|
13
15
|
| Schema["claimAssignment"]["functionHandler"]
|
|
@@ -17,6 +19,7 @@ type AssignmentHandler =
|
|
|
17
19
|
| Schema["reopenAssignment"]["functionHandler"]
|
|
18
20
|
| Schema["retryImmediateAssignment"]["functionHandler"]
|
|
19
21
|
| Schema["getAssignmentContext"]["functionHandler"]
|
|
22
|
+
| Schema["getEditorialContext"]["functionHandler"]
|
|
20
23
|
| Schema["listAssignmentQueue"]["functionHandler"]
|
|
21
24
|
| Schema["listAssignmentsForObject"]["functionHandler"];
|
|
22
25
|
type DataClient = ReturnType<typeof generateClient<Schema>>;
|
|
@@ -32,14 +35,12 @@ const IMMEDIATE_ASSIGNMENT_TYPES = new Set(["procedure.run"]);
|
|
|
32
35
|
const NEWSROOM_SUMMARY_PAYLOAD_ID = "knowledge-raw-payload-newsroom-summary-current";
|
|
33
36
|
const NEWSROOM_SUMMARY_PAYLOAD_OWNER_KIND = "knowledgeRawPayload";
|
|
34
37
|
const SUMMARY_STALE_AFTER_MS = 15 * 60 * 1000;
|
|
35
|
-
const PUBLICATION_DOCTRINE_DEFINITIONS = [
|
|
36
|
-
{ scope: "publication", kind: "mission", label: "Editorial Mission", slug: "editorial-doctrine-mission" },
|
|
37
|
-
{ scope: "publication", kind: "policy", label: "Editorial Policy", slug: "editorial-doctrine-policy" },
|
|
38
|
-
] as const;
|
|
39
38
|
let clientPromise: Promise<DataClient> | null = null;
|
|
39
|
+
let editorialEndpoint: string | undefined;
|
|
40
40
|
|
|
41
41
|
export const handler = async (event: any): Promise<any> => {
|
|
42
42
|
const fieldName = normalizeRequiredString(event.info?.fieldName ?? event.fieldName, "fieldName");
|
|
43
|
+
if (fieldName === "getEditorialContext") return getEditorialContext(event as Parameters<Schema["getEditorialContext"]["functionHandler"]>[0]);
|
|
43
44
|
if (fieldName === "getAssignmentContext") return getAssignmentContext(event as Parameters<Schema["getAssignmentContext"]["functionHandler"]>[0]);
|
|
44
45
|
if (fieldName === "listAssignmentQueue") return listAssignmentQueue(event as Parameters<Schema["listAssignmentQueue"]["functionHandler"]>[0]);
|
|
45
46
|
if (fieldName === "listAssignmentsForObject") return listAssignmentsForObject(event as Parameters<Schema["listAssignmentsForObject"]["functionHandler"]>[0]);
|
|
@@ -315,6 +316,20 @@ async function listAssignmentsForObject(event: Parameters<Schema["listAssignment
|
|
|
315
316
|
return contexts;
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
async function editorialContext(client: DataClient, scope: { publicationKey?: string; sectionId?: string | null; storyLineageId?: string | null; assignmentId?: string | null; maxCharacters?: number | null }) {
|
|
320
|
+
if (!editorialEndpoint) throw new Error("Editorial context requires the configured backend endpoint.");
|
|
321
|
+
return resolveEditorialContext(client as unknown as EditorialClient, {
|
|
322
|
+
...scope, endpoint: editorialEndpoint, tenantId: tenantIdFromEnvironment(), readAttachment: readEditorialAttachment,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function getEditorialContext(event: Parameters<Schema["getEditorialContext"]["functionHandler"]>[0]) {
|
|
327
|
+
const client = await getDataClient();
|
|
328
|
+
return JSON.stringify(await editorialContext(client, {
|
|
329
|
+
...event.arguments, publicationKey: normalizeRequiredString(event.arguments.publicationKey, "publicationKey"),
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
|
|
318
333
|
async function buildAssignmentContext(client: DataClient, assignment: any) {
|
|
319
334
|
const [relations, events] = await Promise.all([
|
|
320
335
|
listRelationPages(client, "listSemanticRelationsBySubjectState", { subjectStateKey: semanticStateKey("assignment", assignment.id) }),
|
|
@@ -329,172 +344,17 @@ async function buildAssignmentContext(client: DataClient, assignment: any) {
|
|
|
329
344
|
label: targetLabelFromRelation(relation),
|
|
330
345
|
detail: relation.predicate,
|
|
331
346
|
}));
|
|
332
|
-
const
|
|
333
|
-
const doctrine =
|
|
347
|
+
const pack = await editorialContext(client, { assignmentId: assignment.id });
|
|
348
|
+
const doctrine = assignmentDoctrine(pack);
|
|
334
349
|
return {
|
|
335
350
|
assignment,
|
|
351
|
+
editorialContext: JSON.stringify(pack),
|
|
336
352
|
doctrine,
|
|
337
353
|
targets,
|
|
338
354
|
events,
|
|
339
355
|
};
|
|
340
356
|
}
|
|
341
357
|
|
|
342
|
-
async function buildAssignmentDoctrineContext(client: DataClient, rootDeskCategory: any | null): Promise<any[]> {
|
|
343
|
-
const definitions = [
|
|
344
|
-
...PUBLICATION_DOCTRINE_DEFINITIONS,
|
|
345
|
-
...(rootDeskCategory ? [
|
|
346
|
-
{
|
|
347
|
-
scope: "desk",
|
|
348
|
-
kind: "mission",
|
|
349
|
-
label: "Desk Mission",
|
|
350
|
-
slug: deskDoctrineSlug(rootDeskCategory.categoryKey, "mission"),
|
|
351
|
-
categoryKey: rootDeskCategory.categoryKey,
|
|
352
|
-
categoryLineageId: rootDeskCategory.lineageId ?? rootDeskCategory.id,
|
|
353
|
-
},
|
|
354
|
-
{
|
|
355
|
-
scope: "desk",
|
|
356
|
-
kind: "policy",
|
|
357
|
-
label: "Desk Policies",
|
|
358
|
-
slug: deskDoctrineSlug(rootDeskCategory.categoryKey, "policy"),
|
|
359
|
-
categoryKey: rootDeskCategory.categoryKey,
|
|
360
|
-
categoryLineageId: rootDeskCategory.lineageId ?? rootDeskCategory.id,
|
|
361
|
-
},
|
|
362
|
-
] : []),
|
|
363
|
-
];
|
|
364
|
-
|
|
365
|
-
const context = [];
|
|
366
|
-
for (const definition of definitions) {
|
|
367
|
-
const record = await getDoctrineRecordBySlug(client, definition.slug);
|
|
368
|
-
if (!record) continue;
|
|
369
|
-
context.push({
|
|
370
|
-
scope: definition.scope,
|
|
371
|
-
kind: definition.kind,
|
|
372
|
-
label: definition.label,
|
|
373
|
-
slug: definition.slug,
|
|
374
|
-
body: compactStringArray(record.body),
|
|
375
|
-
categoryKey: "categoryKey" in definition ? definition.categoryKey : null,
|
|
376
|
-
categoryLineageId: "categoryLineageId" in definition ? definition.categoryLineageId : null,
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
return context;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
async function resolveRootDeskCategory(client: DataClient, assignment: any, relations: any[]): Promise<any | null> {
|
|
383
|
-
const categoryLineageId = assignmentCategoryLineageIdFromRelations(relations);
|
|
384
|
-
if (categoryLineageId) {
|
|
385
|
-
const category = await getCurrentCategoryByLineage(client, categoryLineageId);
|
|
386
|
-
return category ? resolveRootCategory(client, category) : null;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const categoryKey = normalizeOptionalString(assignment.primaryFocusCategoryKey);
|
|
390
|
-
if (categoryKey && assignment.categorySetId) {
|
|
391
|
-
const category = await getCurrentCategoryBySetAndKey(client, assignment.categorySetId, categoryKey);
|
|
392
|
-
return category ? resolveRootCategory(client, category) : null;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
return null;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function assignmentCategoryLineageIdFromRelations(relations: any[]): string | null {
|
|
399
|
-
const direct = relations.find((relation) => (
|
|
400
|
-
relation.relationState === "current"
|
|
401
|
-
&& relation.objectKind === "category"
|
|
402
|
-
&& relation.predicate === "requests_work_on"
|
|
403
|
-
)) ?? relations.find((relation) => (
|
|
404
|
-
relation.relationState === "current"
|
|
405
|
-
&& relation.objectKind === "category"
|
|
406
|
-
));
|
|
407
|
-
return normalizeOptionalString(direct?.objectLineageId);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
async function resolveRootCategory(client: DataClient, category: any): Promise<any | null> {
|
|
411
|
-
let current = category;
|
|
412
|
-
for (let depth = 0; depth < 10; depth += 1) {
|
|
413
|
-
const parentCategoryKey = normalizeOptionalString(current?.parentCategoryKey);
|
|
414
|
-
if (!parentCategoryKey) return current;
|
|
415
|
-
const parent = await getCurrentCategoryBySetAndKey(client, current.categorySetId, parentCategoryKey);
|
|
416
|
-
if (!parent) return current;
|
|
417
|
-
current = parent;
|
|
418
|
-
}
|
|
419
|
-
return current;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
async function getCurrentCategoryByLineage(client: DataClient, lineageId: string): Promise<any | null> {
|
|
423
|
-
const model = client.models.Category as any;
|
|
424
|
-
const query = model.listCategoriesByLineageAndVersion;
|
|
425
|
-
if (typeof query !== "function") return null;
|
|
426
|
-
const categories: any[] = [];
|
|
427
|
-
let nextToken: string | null | undefined;
|
|
428
|
-
do {
|
|
429
|
-
const response: DataClientResult<any[]> = await query({ lineageId }, { limit: 100, nextToken });
|
|
430
|
-
assertNoDataErrors(response.errors, "list Category lineage");
|
|
431
|
-
categories.push(...(response.data ?? []));
|
|
432
|
-
nextToken = response.nextToken;
|
|
433
|
-
} while (nextToken);
|
|
434
|
-
return selectCurrentVersion(categories);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
async function getCurrentCategoryBySetAndKey(client: DataClient, categorySetId: string, categoryKey: string): Promise<any | null> {
|
|
438
|
-
const model = client.models.Category as any;
|
|
439
|
-
const query = model.listCategoriesBySetAndKey;
|
|
440
|
-
if (typeof query !== "function") return null;
|
|
441
|
-
const categories: any[] = [];
|
|
442
|
-
let nextToken: string | null | undefined;
|
|
443
|
-
do {
|
|
444
|
-
const response: DataClientResult<any[]> = await query({ categorySetId, categoryKey }, { limit: 100, nextToken });
|
|
445
|
-
assertNoDataErrors(response.errors, "list Category set/key");
|
|
446
|
-
categories.push(...(response.data ?? []));
|
|
447
|
-
nextToken = response.nextToken;
|
|
448
|
-
} while (nextToken);
|
|
449
|
-
return selectCurrentVersion(categories);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
async function getDoctrineRecordBySlug(client: DataClient, slug: string): Promise<any | null> {
|
|
453
|
-
const model = client.models.Item as any;
|
|
454
|
-
const query = model.itemBySlug;
|
|
455
|
-
if (typeof query !== "function") return null;
|
|
456
|
-
const records: any[] = [];
|
|
457
|
-
let nextToken: string | null | undefined;
|
|
458
|
-
do {
|
|
459
|
-
const response: DataClientResult<any[]> = await query({ slug }, { limit: 100, nextToken });
|
|
460
|
-
assertNoDataErrors(response.errors, "query Item by slug");
|
|
461
|
-
records.push(...(response.data ?? []));
|
|
462
|
-
nextToken = response.nextToken;
|
|
463
|
-
} while (nextToken);
|
|
464
|
-
return records.find((record) => record.type === "doctrine" && record.status === "private")
|
|
465
|
-
?? records.find((record) => record.type === "doctrine")
|
|
466
|
-
?? null;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function selectCurrentVersion(records: any[]): any | null {
|
|
470
|
-
return [...records]
|
|
471
|
-
.filter((record) => record && record.status !== "deprecated")
|
|
472
|
-
.sort((left, right) => {
|
|
473
|
-
const stateDiff = versionStateRank(left.versionState) - versionStateRank(right.versionState);
|
|
474
|
-
if (stateDiff !== 0) return stateDiff;
|
|
475
|
-
return Number(right.versionNumber ?? 0) - Number(left.versionNumber ?? 0);
|
|
476
|
-
})[0] ?? null;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
function versionStateRank(versionState: unknown): number {
|
|
480
|
-
if (versionState === "current") return 0;
|
|
481
|
-
if (versionState === "draft") return 1;
|
|
482
|
-
if (versionState === "superseded") return 8;
|
|
483
|
-
return 5;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
function deskDoctrineSlug(categoryKey: string, kind: "mission" | "policy"): string {
|
|
487
|
-
return `desk-doctrine-${safeDoctrineKey(categoryKey)}-${kind}`;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function safeDoctrineKey(value: string): string {
|
|
491
|
-
return value
|
|
492
|
-
.trim()
|
|
493
|
-
.toLowerCase()
|
|
494
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
495
|
-
.replace(/^-+|-+$/g, "")
|
|
496
|
-
|| "topic";
|
|
497
|
-
}
|
|
498
358
|
|
|
499
359
|
function compactStringArray(value: unknown): string[] {
|
|
500
360
|
if (!Array.isArray(value)) return [];
|
|
@@ -1159,6 +1019,7 @@ async function getDataClient(): Promise<DataClient> {
|
|
|
1159
1019
|
clientPromise = (async () => {
|
|
1160
1020
|
const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(process.env as never);
|
|
1161
1021
|
Amplify.configure(resourceConfig, libraryOptions);
|
|
1022
|
+
editorialEndpoint = resourceConfig.API?.GraphQL?.endpoint;
|
|
1162
1023
|
return generateClient<Schema>();
|
|
1163
1024
|
})();
|
|
1164
1025
|
}
|
|
@@ -2190,7 +2190,7 @@ fn build_openai_messages(
|
|
|
2190
2190
|
) -> Vec<Value> {
|
|
2191
2191
|
let mut messages = vec![json!({
|
|
2192
2192
|
"role": "system",
|
|
2193
|
-
"content": "You are Papyrus, an editorial assistant for an autonomous newsroom. Be concise, accurate, and concrete. Raw console chat turns are working memory and are excluded from default semantic searches unless explicitly requested. When a chat produces durable note, recommend creating a note Message instead of making every chat turn canonical knowledge. Use execute_tactus for Papyrus runtime work. Distinguish references from assignments: Reference.* is for scholarly sources; Assignment.* is for newsroom work items (research, curation, intake). When the user provides a URL and asks to add, register, or file a reference, immediately call Reference.create{ url = \"<url>\", title = \"<optional>\", apply = true }—do not use Reference.register, papyrus.reference.create, or Assignment.create unless they asked for research work. For \"most recent references\" or similar, immediately call Reference.list{ limit = <count>, order = \"newest\" } and summarize. For \"last/latest/most recent research assignment\" (or \"research assignment\" without a specific id), immediately call Assignment.list{ type = \"research\", limit = 1 } (includes research.
|
|
2193
|
+
"content": "You are Papyrus, an editorial assistant for an autonomous newsroom. Be concise, accurate, and concrete. Raw console chat turns are working memory and are excluded from default semantic searches unless explicitly requested. When a chat produces durable note, recommend creating a note Message instead of making every chat turn canonical knowledge. Use execute_tactus for Papyrus runtime work. Distinguish references from assignments: Reference.* is for scholarly sources; Assignment.* is for newsroom work items (research, curation, intake). When the user provides a URL and asks to add, register, or file a reference, immediately call Reference.create{ url = \"<url>\", title = \"<optional>\", apply = true }—do not use Reference.register, papyrus.reference.create, or Assignment.create unless they asked for research work. For \"most recent references\" or similar, immediately call Reference.list{ limit = <count>, order = \"newest\" } and summarize. For \"last/latest/most recent research assignment\" (or \"research assignment\" without a specific id), immediately call Assignment.list{ type = \"research\", limit = 1 } (includes research.candidate, research.tavily-deep, and other research.* types), then Assignment.get{ id = \"<that id>\" } if the user wants detail—do not use Reference.list and do not list all assignment types (curation.reference-intake is not a research assignment). To create research work, use Assignment.create{ type = \"research\", title = \"...\", apply = true }."
|
|
2194
2194
|
})];
|
|
2195
2195
|
if !static_prompt.publication_mission.trim().is_empty() {
|
|
2196
2196
|
messages.push(json!({
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { tenantScopedValue } from "../../../lib/tenant-keys";
|
|
3
|
+
|
|
4
|
+
export type EditorialRecord = Record<string, any>;
|
|
5
|
+
type Result = { data?: EditorialRecord | EditorialRecord[] | null; errors?: unknown; nextToken?: string | null };
|
|
6
|
+
type Model = Record<string, (args: Record<string, any>, options?: Record<string, any>) => Promise<Result>>;
|
|
7
|
+
export type EditorialClient = { models: Record<string, Model> };
|
|
8
|
+
export type EditorialOptions = {
|
|
9
|
+
publicationKey?: string;
|
|
10
|
+
endpoint: string;
|
|
11
|
+
tenantId?: string;
|
|
12
|
+
sectionId?: string | null;
|
|
13
|
+
storyLineageId?: string | null;
|
|
14
|
+
assignmentId?: string | null;
|
|
15
|
+
maxCharacters?: number | null;
|
|
16
|
+
readAttachment: (attachment: EditorialRecord) => Promise<string>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function checked(response: Result, operation: string) {
|
|
20
|
+
if (response.errors && (!Array.isArray(response.errors) || response.errors.length)) throw new Error(`${operation}: ${JSON.stringify(response.errors)}`);
|
|
21
|
+
return response;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function pages(client: EditorialClient, model: string, index: string, args: EditorialRecord) {
|
|
25
|
+
const rows: EditorialRecord[] = [];
|
|
26
|
+
let nextToken: string | null | undefined;
|
|
27
|
+
do {
|
|
28
|
+
const response = checked(await client.models[model][index](args, { limit: 100, nextToken }), `read ${model} ${index}`);
|
|
29
|
+
rows.push(...(response.data as EditorialRecord[] ?? []));
|
|
30
|
+
nextToken = response.nextToken;
|
|
31
|
+
} while (nextToken);
|
|
32
|
+
return rows;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function get(client: EditorialClient, model: string, id: string) {
|
|
36
|
+
const row = checked(await client.models[model].get({ id }), `read ${model}`).data as EditorialRecord | null;
|
|
37
|
+
if (!row) throw new Error(`${model} not found: ${id}`);
|
|
38
|
+
return row;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function tenant(row: EditorialRecord, tenantId?: string) {
|
|
42
|
+
if ((row.tenantId || null) !== (tenantId || null)) throw new Error("Editorial record tenant does not match the configured authoring scope.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function canonical(value: any): any {
|
|
46
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
47
|
+
if (value !== null && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
function digest(value: string) { return createHash("sha256").update(value, "utf8").digest("hex"); }
|
|
51
|
+
function entry(scope: string, kind: string, text: string, record: EditorialRecord) {
|
|
52
|
+
return { scope, kind, text, source: {
|
|
53
|
+
...Object.fromEntries(["id", "lineageId", "versionNumber", "updatedAt", "contentHash"].map(key => [key, record[key] ?? null])),
|
|
54
|
+
sha256: digest(text),
|
|
55
|
+
} };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildEditorialContext(publication: EditorialRecord, scope: EditorialRecord, entries: EditorialRecord[], warnings: string[], maxCharacters = 0) {
|
|
59
|
+
const lines = [`Editorial context: ${publication.key}`, "Source: cloud",
|
|
60
|
+
"Precedence: publication > section > assignment. Surface conflicts; narrower instructions do not silently override publication policy.",
|
|
61
|
+
"Private operating guidance; not evidence or reader-facing copy."];
|
|
62
|
+
for (const value of entries) lines.push("", `## ${value.scope}: ${value.kind}`, value.text);
|
|
63
|
+
const text = lines.join("\n") + "\n";
|
|
64
|
+
const totalCharacters = Array.from(text).length;
|
|
65
|
+
if (!Number.isInteger(maxCharacters) || maxCharacters < 0) throw new Error("Editorial context budget must be nonnegative.");
|
|
66
|
+
if (maxCharacters && totalCharacters > maxCharacters) throw new Error(`Editorial context budget ${maxCharacters} cannot hold ${totalCharacters} required characters; policy was not truncated.`);
|
|
67
|
+
const payload = { schemaVersion: 1, rendererVersion: "editorial-context/v1", publication, scope, sourceMode: "cloud",
|
|
68
|
+
precedence: ["publication", "section", "assignment"], entries, sources: entries.map(value => value.source), warnings, text, totalCharacters };
|
|
69
|
+
return { ...payload, contextHash: digest(JSON.stringify(canonical(payload))), resolvedAt: new Date().toISOString() };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Private source adapter for the same deterministic contract exposed by papyrus editorial context. */
|
|
73
|
+
export async function resolveEditorialContext(client: EditorialClient, options: EditorialOptions) {
|
|
74
|
+
if (!options.endpoint) throw new Error("Explicit publication endpoint is required for hosted editorial context.");
|
|
75
|
+
const corpora = await pages(client, "KnowledgeCorpus", "listKnowledgeCorporaByRoleAndName", { role: "canonical" });
|
|
76
|
+
const requested = options.publicationKey;
|
|
77
|
+
const normalized = requested?.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 140);
|
|
78
|
+
if (corpora.length !== 1 || !corpora[0].id?.startsWith("knowledge-corpus-") || corpora[0].role !== "canonical" || !corpora[0].name?.trim() ||
|
|
79
|
+
(requested !== undefined && (!normalized || corpora[0].id !== `knowledge-corpus-${normalized}`))) {
|
|
80
|
+
throw new Error("Requested publication does not match the hosted canonical corpus; missing or ambiguous publication binding.");
|
|
81
|
+
}
|
|
82
|
+
const publication = { key: corpora[0].id.slice("knowledge-corpus-".length), name: corpora[0].name,
|
|
83
|
+
corpusId: corpora[0].id, tenantId: options.tenantId ?? null, endpoint: options.endpoint };
|
|
84
|
+
const entries: EditorialRecord[] = [];
|
|
85
|
+
const warnings: string[] = [];
|
|
86
|
+
let sectionId = options.sectionId || null;
|
|
87
|
+
let storyLineageId = options.storyLineageId || null;
|
|
88
|
+
const assignmentId = options.assignmentId || null;
|
|
89
|
+
let assignment: EditorialRecord | undefined;
|
|
90
|
+
if (assignmentId) {
|
|
91
|
+
assignment = await get(client, "Assignment", assignmentId);
|
|
92
|
+
const assignedStory = assignment.itemLineageId;
|
|
93
|
+
if (storyLineageId && assignedStory && storyLineageId !== assignedStory) throw new Error("Requested story conflicts with assignment story lineage.");
|
|
94
|
+
storyLineageId ||= assignedStory || null;
|
|
95
|
+
const assignedSection = assignment.sectionKey || assignment.sectionId;
|
|
96
|
+
if (sectionId && assignedSection && sectionId !== assignedSection) throw new Error("Requested section conflicts with assignment section.");
|
|
97
|
+
sectionId ||= assignedSection || null;
|
|
98
|
+
warnings.push("Assignment policy snapshots are ignored; current publication and section records govern. Surface any conflicting assignment instructions.");
|
|
99
|
+
}
|
|
100
|
+
if (storyLineageId) {
|
|
101
|
+
const versions = await pages(client, "Item", "listItemsByLineageAndVersion", { lineageId: storyLineageId });
|
|
102
|
+
if (!versions.length) throw new Error(`Story not found: ${storyLineageId}`);
|
|
103
|
+
const story = versions.reduce((a, b) => Number(b.versionNumber || 0) > Number(a.versionNumber || 0) ? b : a);
|
|
104
|
+
tenant(story, options.tenantId);
|
|
105
|
+
if (sectionId && story.section && sectionId !== story.section) throw new Error("Requested section conflicts with story section.");
|
|
106
|
+
sectionId ||= story.section || null;
|
|
107
|
+
}
|
|
108
|
+
for (const kind of ["mission", "policy"]) {
|
|
109
|
+
const slug = `editorial-doctrine-${kind}`;
|
|
110
|
+
const rows = await pages(client, "Item", options.tenantId ? "itemByTenantSlug" : "itemBySlug",
|
|
111
|
+
options.tenantId ? { tenantSlug: tenantScopedValue(options.tenantId, slug) } : { slug });
|
|
112
|
+
const current = rows.filter(row => row.type === "doctrine" && row.status === "private" && row.versionState === "current");
|
|
113
|
+
if (current.length !== 1) throw new Error(`Publication ${kind}: expected exactly one current record, found ${current.length}.`);
|
|
114
|
+
const record = current[0];
|
|
115
|
+
tenant(record, options.tenantId);
|
|
116
|
+
const text = record.bodyMarkus ? String(record.bodyMarkus).trim() : (record.body ?? []).map((part: any) => String(part).trim()).filter(Boolean).join("\n\n");
|
|
117
|
+
if (!text) throw new Error(`Required publication ${kind} is empty.`);
|
|
118
|
+
entries.push(entry("publication", kind, text, record));
|
|
119
|
+
}
|
|
120
|
+
if (sectionId) {
|
|
121
|
+
const section = await get(client, "NewsroomSection", sectionId);
|
|
122
|
+
tenant(section, options.tenantId);
|
|
123
|
+
for (const [kind, key] of [["mission", "editorialMission"], ["policy", "editorialPolicy"], ["guidance", "assignmentGuidance"], ["kill criteria", "killCriteria"]]) {
|
|
124
|
+
const text = String(section[key] || "").trim();
|
|
125
|
+
if (text) entries.push(entry("section", kind, text, section));
|
|
126
|
+
else if (kind === "mission" || kind === "policy") warnings.push(`Section ${section.id} intentionally has an empty ${kind} slot; publication doctrine still applies.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (assignment) {
|
|
130
|
+
if (options.tenantId && !storyLineageId && !sectionId) throw new Error("A tenant assignment needs a scoped story or section to establish its publication.");
|
|
131
|
+
const attachments = await pages(client, "ModelAttachment", "listModelAttachmentsByOwnerRoleAndSortKey", { ownerId: assignment.id });
|
|
132
|
+
let foundBrief = false;
|
|
133
|
+
for (const [role, kind] of [["assignment_brief", "brief"], ["assignment_instructions", "instructions"]]) {
|
|
134
|
+
const matching = attachments.filter(row => row.role === role && row.status !== "deleted");
|
|
135
|
+
if (matching.length > 1) throw new Error(`Ambiguous assignment ${kind} attachments.`);
|
|
136
|
+
if (matching.length) {
|
|
137
|
+
const attachment = matching[0];
|
|
138
|
+
if (attachment.ownerKind !== "assignment" || attachment.ownerId !== assignment.id) throw new Error("Assignment attachment owner does not match the requested scope.");
|
|
139
|
+
const text = (await options.readAttachment(attachment)).trim();
|
|
140
|
+
if (!text) throw new Error(`Assignment ${kind} attachment is empty.`);
|
|
141
|
+
entries.push(entry("assignment", kind, text, attachment));
|
|
142
|
+
foundBrief ||= kind === "brief";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!foundBrief && assignment.summary) {
|
|
146
|
+
entries.push(entry("assignment", "brief", assignment.summary, assignment));
|
|
147
|
+
warnings.push("No assignment brief attachment exists; the Assignment summary is supplied as its brief.");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return buildEditorialContext(publication, { sectionId, storyLineageId, assignmentId }, entries, warnings, options.maxCharacters ?? 0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function assignmentDoctrine(pack: Awaited<ReturnType<typeof resolveEditorialContext>>) {
|
|
154
|
+
return pack.entries.filter(value => value.scope !== "assignment").map(value => ({
|
|
155
|
+
scope: value.scope === "section" ? "desk" : "publication", kind: value.kind,
|
|
156
|
+
label: `${value.scope === "section" ? "Section" : "Editorial"} ${value.kind}`,
|
|
157
|
+
slug: value.scope === "section" ? `desk-section-${pack.scope.sectionId}-${value.kind.replaceAll(" ", "-")}` : `editorial-doctrine-${value.kind}`,
|
|
158
|
+
body: [value.text], ...(value.scope === "section" ? { categoryKey: pack.scope.sectionId, categoryLineageId: pack.scope.sectionId } : {}),
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
@@ -135,6 +135,15 @@ async function readTextFromStorage(storagePath: string): Promise<string> {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/** Strict read for required private editorial payloads; storage failures propagate. */
|
|
139
|
+
export async function readEditorialAttachment(attachment: { storagePath?: string | null }): Promise<string> {
|
|
140
|
+
if (!attachment.storagePath) throw new Error("Editorial attachment has no storage path.");
|
|
141
|
+
const result = await s3.send(new GetObjectCommand({ Bucket: payloadBucketName(), Key: attachment.storagePath }));
|
|
142
|
+
const text = await result.Body?.transformToString("utf8");
|
|
143
|
+
if (!text?.trim()) throw new Error("Editorial attachment is empty.");
|
|
144
|
+
return text;
|
|
145
|
+
}
|
|
146
|
+
|
|
138
147
|
function isMissingStorageKeyError(error: unknown): boolean {
|
|
139
148
|
if (!error || typeof error !== "object") return false;
|
|
140
149
|
const record = error as Record<string, unknown>;
|
|
@@ -3,7 +3,7 @@ import { getSiteRenderer } from "../../../../../lib/site-renderer";
|
|
|
3
3
|
import { getCachedEditionItem, loadCachedEditionContent } from "../../../../../lib/cached-content-repository";
|
|
4
4
|
import { getEditionDatePath, parseEditionArticleRoute } from "../../../../../lib/edition-routes";
|
|
5
5
|
import { buildPresentationFooterEntries } from "../../../../../lib/presentation-footer";
|
|
6
|
-
import { SITE_BRAND } from "../../../../../lib/site-brand";
|
|
6
|
+
import { SITE_BRAND, editionsEnabled } from "../../../../../lib/site-brand";
|
|
7
7
|
import { generateEditionItemStaticParams } from "../../../../../lib/reader-static-params";
|
|
8
8
|
// Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
|
|
9
9
|
export const revalidate = 60;
|
|
@@ -39,6 +39,7 @@ export async function generateMetadata({ params }: DateScopedArticlePageProps) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export default async function DateScopedArticlePage({ params }: DateScopedArticlePageProps) {
|
|
42
|
+
if (!editionsEnabled()) notFound();
|
|
42
43
|
const { year, month, day, articleSlug } = await params;
|
|
43
44
|
const route = parseEditionArticleRoute({ year, month, day, articleSlug });
|
|
44
45
|
if (!route) notFound();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { notFound, redirect } from "next/navigation";
|
|
2
|
+
import { editionsEnabled } from "../../../../lib/site-brand";
|
|
2
3
|
import { siteServesNextReaderRoutes } from "../../../../lib/reader-static-params";
|
|
3
4
|
import { getSiteRenderer } from "../../../../lib/site-renderer";
|
|
4
5
|
import type { PresentationTarget } from "../../../../renderers/pretext";
|
|
@@ -23,7 +24,7 @@ export async function EditionRoutePage({
|
|
|
23
24
|
lockedPresentation,
|
|
24
25
|
target = { kind: "edition" },
|
|
25
26
|
}: EditionRoutePageProps) {
|
|
26
|
-
if (!siteServesNextReaderRoutes()) notFound();
|
|
27
|
+
if (!editionsEnabled() || !siteServesNextReaderRoutes()) notFound();
|
|
27
28
|
const route = parseEditionDateRoute({ year, month, day });
|
|
28
29
|
if (!route) notFound();
|
|
29
30
|
if (!route.isCanonical) redirect(route.canonicalPath);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { notFound, redirect } from "next/navigation";
|
|
2
2
|
import { EditionRoutePage } from "../../edition-route-page";
|
|
3
3
|
import { parseEditionPageRoute } from "../../../../../../lib/edition-routes";
|
|
4
|
-
import { getDefaultPretextLayout, getForcedPresentation } from "../../../../../../lib/site-brand";
|
|
4
|
+
import { editionsEnabled, getDefaultPretextLayout, getForcedPresentation } from "../../../../../../lib/site-brand";
|
|
5
5
|
import { generateEditionPageStaticParams } from "../../../../../../lib/reader-static-params";
|
|
6
6
|
// Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
|
|
7
7
|
export const revalidate = 60;
|
|
@@ -20,6 +20,7 @@ type EditionPageRouteProps = {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
export default async function EditionPageRoute({ params }: EditionPageRouteProps) {
|
|
23
|
+
if (!editionsEnabled()) notFound();
|
|
23
24
|
const { year, month, day, pageNumber } = await params;
|
|
24
25
|
const route = parseEditionPageRoute({ year, month, day, pageNumber });
|
|
25
26
|
if (!route) notFound();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { notFound } from "next/navigation";
|
|
2
|
+
import { editionsEnabled } from "../../../../lib/site-brand";
|
|
1
3
|
import { EditionRoutePage } from "./edition-route-page";
|
|
2
4
|
import { generateEditionDateStaticParams } from "../../../../lib/reader-static-params";
|
|
3
5
|
|
|
@@ -17,6 +19,7 @@ type DateEditionPageProps = {
|
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export default async function DateEditionPage({ params }: DateEditionPageProps) {
|
|
22
|
+
if (!editionsEnabled()) notFound();
|
|
20
23
|
const { year, month, day } = await params;
|
|
21
24
|
return <EditionRoutePage day={day} month={month} year={year} />;
|
|
22
25
|
}
|
|
@@ -2,6 +2,7 @@ import { notFound, redirect } from "next/navigation";
|
|
|
2
2
|
import { EditionRoutePage } from "../../edition-route-page";
|
|
3
3
|
import { createSectionKey } from "../../../../../../lib/edition-sections";
|
|
4
4
|
import { parseEditionSectionRoute } from "../../../../../../lib/edition-routes";
|
|
5
|
+
import { editionsEnabled } from "../../../../../../lib/site-brand";
|
|
5
6
|
import { generateEditionSectionStaticParams } from "../../../../../../lib/reader-static-params";
|
|
6
7
|
// Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
|
|
7
8
|
export const revalidate = 60;
|
|
@@ -20,6 +21,7 @@ type EditionSectionRouteProps = {
|
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
export default async function EditionSectionRoute({ params }: EditionSectionRouteProps) {
|
|
24
|
+
if (!editionsEnabled()) notFound();
|
|
23
25
|
const { year, month, day, sectionKey } = await params;
|
|
24
26
|
const normalizedSectionKey = createSectionKey(sectionKey);
|
|
25
27
|
const route = parseEditionSectionRoute({ year, month, day, sectionKey: normalizedSectionKey });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
import { editionsEnabled } from "../../../../lib/site-brand";
|
|
2
3
|
import { loadArchiveEditionPreviews, clampArchiveLimit } from "../../../../lib/archive-data";
|
|
3
4
|
import { ARCHIVE_BATCH_SIZE } from "../../../../lib/archive-types";
|
|
4
5
|
|
|
@@ -6,6 +7,7 @@ import { ARCHIVE_BATCH_SIZE } from "../../../../lib/archive-types";
|
|
|
6
7
|
export const revalidate = 60;
|
|
7
8
|
|
|
8
9
|
export async function GET(request: Request) {
|
|
10
|
+
if (!editionsEnabled()) return new NextResponse(null, { status: 404 });
|
|
9
11
|
const url = new URL(request.url);
|
|
10
12
|
const cursor = url.searchParams.get("cursor");
|
|
11
13
|
const requestedLimit = Number(url.searchParams.get("limit") ?? ARCHIVE_BATCH_SIZE);
|
package/app/archive/page.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { notFound } from "next/navigation";
|
|
2
|
+
import { editionsEnabled } from "../../lib/site-brand";
|
|
1
3
|
import { ArchiveShell } from "../../components/archive-shell";
|
|
2
4
|
import { ArchiveGrid } from "../../components/archive-grid";
|
|
3
5
|
import { loadArchiveEditionPreviews } from "../../lib/archive-data";
|
|
@@ -7,6 +9,7 @@ import { ARCHIVE_BATCH_SIZE } from "../../lib/archive-types";
|
|
|
7
9
|
export const revalidate = 60;
|
|
8
10
|
|
|
9
11
|
export default async function ArchivePage() {
|
|
12
|
+
if (!editionsEnabled()) notFound();
|
|
10
13
|
const initialBatch = await loadArchiveEditionPreviews({ limit: ARCHIVE_BATCH_SIZE });
|
|
11
14
|
|
|
12
15
|
return (
|
package/app/page.tsx
CHANGED
|
@@ -15,7 +15,10 @@ import {
|
|
|
15
15
|
type PublicPlaceholderTopic,
|
|
16
16
|
} from "../lib/public-placeholder-config";
|
|
17
17
|
import type { PublicationItem } from "../lib/publication-items";
|
|
18
|
-
import { SITE_BRAND, rootRoute } from "../lib/site-brand";
|
|
18
|
+
import { SITE_BRAND, editionsEnabled, rootRoute } from "../lib/site-brand";
|
|
19
|
+
import { ItemStreamView } from "../components/item-stream-view";
|
|
20
|
+
import { ITEM_STREAM_RECENT_EDITION_COUNT, buildItemStream, selectNewestStreamItems } from "../lib/item-stream";
|
|
21
|
+
import { listCachedPublishedEditions } from "../lib/cached-content-repository";
|
|
19
22
|
import { notFound, redirect } from "next/navigation";
|
|
20
23
|
import { getReaderBasePath } from "../lib/reader-base-path";
|
|
21
24
|
import { buildRootRedirectTarget } from "../lib/root-route-redirect";
|
|
@@ -27,6 +30,7 @@ export const dynamic = "force-dynamic";
|
|
|
27
30
|
type HomePageProps = {
|
|
28
31
|
searchParams?: Promise<{
|
|
29
32
|
scenario?: string | string[];
|
|
33
|
+
section?: string | string[];
|
|
30
34
|
code?: string | string[];
|
|
31
35
|
state?: string | string[];
|
|
32
36
|
error?: string | string[];
|
|
@@ -59,6 +63,13 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|
|
59
63
|
notFound();
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
if (!editionsEnabled()) {
|
|
67
|
+
const sectionParam = resolvedSearchParams?.section;
|
|
68
|
+
const sectionKey = Array.isArray(sectionParam) ? sectionParam[0] : sectionParam;
|
|
69
|
+
const items = await loadNewestStreamItemsOrNone();
|
|
70
|
+
return <ItemStreamView stream={buildItemStream(items, sectionKey)} />;
|
|
71
|
+
}
|
|
72
|
+
|
|
62
73
|
// Resolved only on the reader path: Markus brands (renderer "markus") have
|
|
63
74
|
// no Next.js reader and getSiteRenderer() throws for them, but they can
|
|
64
75
|
// still serve the newsroom at the root via rootRoute above.
|
|
@@ -88,6 +99,24 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|
|
88
99
|
return <siteRenderer.renderEdition content={content} />;
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
async function loadNewestStreamItemsOrNone() {
|
|
103
|
+
try {
|
|
104
|
+
return await loadNewestStreamItems();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (!isMissingReaderBackendError(error)) throw error;
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function loadNewestStreamItems() {
|
|
112
|
+
const { editions } = await listCachedPublishedEditions({ limit: ITEM_STREAM_RECENT_EDITION_COUNT });
|
|
113
|
+
const newestFirst = [...editions].sort((left, right) => right.editionDate.localeCompare(left.editionDate));
|
|
114
|
+
const contents = await Promise.all(
|
|
115
|
+
newestFirst.map((edition) => loadCachedEditionContent({ editionDate: edition.editionDate })),
|
|
116
|
+
);
|
|
117
|
+
return selectNewestStreamItems(contents);
|
|
118
|
+
}
|
|
119
|
+
|
|
91
120
|
async function loadLatestGraphQLEdition(): Promise<EditionContent | null> {
|
|
92
121
|
const latestEdition = await getCachedLatestPublishedEdition();
|
|
93
122
|
if (!latestEdition) return null;
|
package/bin/papyrus-infra.mjs
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
* papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]
|
|
4
4
|
* papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]
|
|
5
5
|
* papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]
|
|
7
|
+
* Synthesizes the app-shell stack for a publication from its own site.json,
|
|
8
|
+
* prints one of the generated Amplify build specs, or deploys the stack from
|
|
9
|
+
* inside the Amplify build of the one branch that owns it.
|
|
9
10
|
*/
|
|
10
11
|
import fs from "node:fs";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
11
13
|
import path from "node:path";
|
|
12
14
|
import { createRequire } from "node:module";
|
|
13
15
|
import { buildSpecFor } from "../infra/build-specs.js";
|
|
@@ -17,6 +19,7 @@ const USAGE = [
|
|
|
17
19
|
"Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
|
|
18
20
|
" papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
|
|
19
21
|
" papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
|
|
22
|
+
" papyrus-infra deploy --site infra/site.json [--only-branch main] [--only-app-id ID] [--out cdk.out] [--account A] [--region R]",
|
|
20
23
|
].join("\n");
|
|
21
24
|
|
|
22
25
|
const args = process.argv.slice(2);
|
|
@@ -25,7 +28,7 @@ const opt = (name, fallback) => {
|
|
|
25
28
|
return i >= 0 ? args[i + 1] : fallback;
|
|
26
29
|
};
|
|
27
30
|
const command = args[0];
|
|
28
|
-
if (command !== "synth" && command !== "print-buildspec") {
|
|
31
|
+
if (command !== "synth" && command !== "print-buildspec" && command !== "deploy") {
|
|
29
32
|
console.error(USAGE);
|
|
30
33
|
process.exit(command ? 1 : 0);
|
|
31
34
|
}
|
|
@@ -77,6 +80,41 @@ if (command === "print-buildspec") {
|
|
|
77
80
|
process.exit(0);
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
if (command === "deploy") {
|
|
84
|
+
const buildAppId = process.env.AWS_APP_ID;
|
|
85
|
+
const buildBranch = process.env.AWS_BRANCH;
|
|
86
|
+
const owningBranch = opt("only-branch", "main");
|
|
87
|
+
const owningAppId = opt("only-app-id");
|
|
88
|
+
if (!buildAppId || !buildBranch) {
|
|
89
|
+
console.log("papyrus-infra deploy: not running inside an Amplify build, skipping");
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
if (buildBranch !== owningBranch || (owningAppId && buildAppId !== owningAppId)) {
|
|
93
|
+
console.log(`papyrus-infra deploy: ${buildAppId}/${buildBranch} does not own the stack, skipping`);
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
const outputDirectory = path.resolve(opt("out", process.env.CDK_OUTDIR ?? "cdk.out"));
|
|
97
|
+
const synthCommand = [
|
|
98
|
+
process.execPath,
|
|
99
|
+
path.resolve(process.argv[1]),
|
|
100
|
+
"synth",
|
|
101
|
+
"--site",
|
|
102
|
+
path.resolve(opt("site", "infra/site.json")),
|
|
103
|
+
"--out",
|
|
104
|
+
outputDirectory,
|
|
105
|
+
"--account",
|
|
106
|
+
opt("account", process.env.CDK_DEFAULT_ACCOUNT ?? "335163751677"),
|
|
107
|
+
"--region",
|
|
108
|
+
opt("region", process.env.CDK_DEFAULT_REGION ?? "us-east-1"),
|
|
109
|
+
].map((part) => JSON.stringify(part)).join(" ");
|
|
110
|
+
const deployment = spawnSync(
|
|
111
|
+
"npx",
|
|
112
|
+
["--yes", "aws-cdk@2", "deploy", "--app", synthCommand, "--require-approval", "never", `AmplifyAppShell-${config.siteId}`],
|
|
113
|
+
{ stdio: "inherit" },
|
|
114
|
+
);
|
|
115
|
+
process.exit(deployment.status ?? 1);
|
|
116
|
+
}
|
|
117
|
+
|
|
80
118
|
let AmplifyAppShellStack;
|
|
81
119
|
try {
|
|
82
120
|
({ AmplifyAppShellStack } = await import("../infra/index.js"));
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import type { ItemStream } from "../lib/item-stream";
|
|
3
|
+
import { toPublicReaderPath } from "../lib/reader-base-path";
|
|
4
|
+
import { SITE_BRAND } from "../lib/site-brand";
|
|
5
|
+
|
|
6
|
+
/** The home of a publication without editions: a masthead, section filters and the newest items. */
|
|
7
|
+
export function ItemStreamView({ stream }: { stream: ItemStream }) {
|
|
8
|
+
const homePath = toPublicReaderPath("/");
|
|
9
|
+
const tagline = SITE_BRAND.mastheadTagline ?? null;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<main className="presentation-page presentation-page--blog" data-presentation-engine="item-stream">
|
|
13
|
+
<header className="presentation-header">
|
|
14
|
+
<div className="presentation-header__copy-stack">
|
|
15
|
+
<h1>
|
|
16
|
+
<Link href={homePath}>{SITE_BRAND.mastheadTitle}</Link>
|
|
17
|
+
</h1>
|
|
18
|
+
<div className="presentation-header__meta">
|
|
19
|
+
<span className="presentation-header__subtitle">{SITE_BRAND.mastheadSubtitle}</span>
|
|
20
|
+
</div>
|
|
21
|
+
{tagline ? <span className="presentation-header__tagline">{tagline}</span> : null}
|
|
22
|
+
</div>
|
|
23
|
+
</header>
|
|
24
|
+
{stream.sections.length > 1 ? (
|
|
25
|
+
<nav className="presentation-section-nav" aria-label="Sections">
|
|
26
|
+
<Link href={homePath} aria-current={stream.activeSectionKey === null ? "page" : undefined}>
|
|
27
|
+
All
|
|
28
|
+
</Link>
|
|
29
|
+
{stream.sections.map((section) => (
|
|
30
|
+
<Link
|
|
31
|
+
aria-current={stream.activeSectionKey === section.key ? "page" : undefined}
|
|
32
|
+
href={`${homePath}?section=${encodeURIComponent(section.key)}`}
|
|
33
|
+
key={section.key}
|
|
34
|
+
>
|
|
35
|
+
{section.label}
|
|
36
|
+
</Link>
|
|
37
|
+
))}
|
|
38
|
+
</nav>
|
|
39
|
+
) : null}
|
|
40
|
+
{stream.items.length === 0 ? (
|
|
41
|
+
<section className="item-stream-empty" data-item-stream-empty="true">
|
|
42
|
+
<p>Nothing published yet</p>
|
|
43
|
+
</section>
|
|
44
|
+
) : (
|
|
45
|
+
<div className="blog-sections">
|
|
46
|
+
{stream.items.map((item) => (
|
|
47
|
+
<article className="presentation-item presentation-item--blog" data-item-id={item.slug} key={item.slug}>
|
|
48
|
+
<div className="presentation-item__copy">
|
|
49
|
+
<header className="presentation-item__header">
|
|
50
|
+
<p>{item.section ?? "General"}</p>
|
|
51
|
+
<h2>
|
|
52
|
+
<Link href={toPublicReaderPath(`/articles/${encodeURIComponent(item.slug)}`)}>{item.headline}</Link>
|
|
53
|
+
</h2>
|
|
54
|
+
{item.deck ? <span>{item.deck}</span> : null}
|
|
55
|
+
</header>
|
|
56
|
+
</div>
|
|
57
|
+
</article>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
</main>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -117,7 +117,7 @@ function AssignmentDetailPanel({
|
|
|
117
117
|
detailExtras?: ReactNode;
|
|
118
118
|
}) {
|
|
119
119
|
const terminal = assignment.status === "completed" || assignment.status === "canceled";
|
|
120
|
-
const isReporting = assignment.assignmentTypeKey === "reporting.
|
|
120
|
+
const isReporting = assignment.assignmentTypeKey === "reporting.candidate";
|
|
121
121
|
|
|
122
122
|
return (
|
|
123
123
|
<div
|
|
@@ -12368,7 +12368,7 @@ function ReportingStoryBudgetBoard({
|
|
|
12368
12368
|
section={section}
|
|
12369
12369
|
selectedAssignmentId={selectedAssignmentId}
|
|
12370
12370
|
/>
|
|
12371
|
-
)) : <EmptyRow label="No reporting
|
|
12371
|
+
)) : <EmptyRow label="No reporting candidate assignments found" />}
|
|
12372
12372
|
</div>
|
|
12373
12373
|
);
|
|
12374
12374
|
}
|
|
@@ -14568,7 +14568,7 @@ function buildUiReportingPacketReviewPlan({
|
|
|
14568
14568
|
event: AssignmentEventRecord;
|
|
14569
14569
|
relations: SemanticRelationRecord[];
|
|
14570
14570
|
} {
|
|
14571
|
-
if (assignment.assignmentTypeKey !== "reporting.
|
|
14571
|
+
if (assignment.assignmentTypeKey !== "reporting.candidate") throw new Error("Only reporting candidate assignments can review reporting packets.");
|
|
14572
14572
|
if (message.messageKind !== "reporting_context_packet") throw new Error("Only reporting context packets can be reviewed.");
|
|
14573
14573
|
if (decision === "merge" && !targetItem?.id && !targetItemId.trim()) throw new Error("Merge Packet requires a target Item ID.");
|
|
14574
14574
|
const resolvedTargetItem = targetItem ?? (targetItemId.trim() ? { id: targetItemId.trim(), lineageId: targetItemId.trim(), versionNumber: null } : null);
|
package/infra/build-specs.js
CHANGED
|
@@ -43,7 +43,9 @@ function frontendSection(preBuildCommands, buildCommands, artifactsDirectory, ca
|
|
|
43
43
|
lines.push(" build:", " commands:", ...commandLines(buildCommands, " "), " artifacts:", ` baseDirectory: ${artifactsDirectory}`, " files:", " - '**/*'", " cache:", " paths:", ...cachePaths.map((cachePath) => ` - ${cachePath}`));
|
|
44
44
|
return lines;
|
|
45
45
|
}
|
|
46
|
+
const DEPLOY_STACK_IN_BUILD = "npx papyrus-infra deploy --site infra/site.json --only-branch main";
|
|
46
47
|
export function cmsProductionBuildSpec(config) {
|
|
48
|
+
const deployStackCommands = config.cms?.deployStackInBuild ? [DEPLOY_STACK_IN_BUILD] : [];
|
|
47
49
|
return [
|
|
48
50
|
"version: 1",
|
|
49
51
|
"backend:",
|
|
@@ -52,6 +54,7 @@ export function cmsProductionBuildSpec(config) {
|
|
|
52
54
|
" commands:",
|
|
53
55
|
...commandLines([
|
|
54
56
|
NPM_CLEAN_INSTALL,
|
|
57
|
+
...deployStackCommands,
|
|
55
58
|
"if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true; fi",
|
|
56
59
|
"npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID",
|
|
57
60
|
], " "),
|
package/infra/build-specs.ts
CHANGED
|
@@ -67,7 +67,10 @@ function frontendSection(preBuildCommands: string[], buildCommands: string[], ar
|
|
|
67
67
|
return lines;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
const DEPLOY_STACK_IN_BUILD = "npx papyrus-infra deploy --site infra/site.json --only-branch main";
|
|
71
|
+
|
|
70
72
|
export function cmsProductionBuildSpec(config: AmplifyAppShellSiteConfig): string {
|
|
73
|
+
const deployStackCommands = config.cms?.deployStackInBuild ? [DEPLOY_STACK_IN_BUILD] : [];
|
|
71
74
|
return [
|
|
72
75
|
"version: 1",
|
|
73
76
|
"backend:",
|
|
@@ -77,6 +80,7 @@ export function cmsProductionBuildSpec(config: AmplifyAppShellSiteConfig): strin
|
|
|
77
80
|
...commandLines(
|
|
78
81
|
[
|
|
79
82
|
NPM_CLEAN_INSTALL,
|
|
83
|
+
...deployStackCommands,
|
|
80
84
|
"if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true; fi",
|
|
81
85
|
"npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID",
|
|
82
86
|
],
|
package/infra/site-config.js
CHANGED
|
@@ -199,7 +199,7 @@ function parseStagingPreview(value) {
|
|
|
199
199
|
}
|
|
200
200
|
function parseCms(value) {
|
|
201
201
|
const cmsRecord = requireObject(value, "cms");
|
|
202
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
|
|
202
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
|
|
203
203
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
204
204
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
205
205
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -210,6 +210,9 @@ function parseCms(value) {
|
|
|
210
210
|
if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
|
|
211
211
|
fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
|
|
212
212
|
}
|
|
213
|
+
if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
|
|
214
|
+
fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
|
|
215
|
+
}
|
|
213
216
|
const cms = {
|
|
214
217
|
appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
|
|
215
218
|
domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
|
|
@@ -222,6 +225,7 @@ function parseCms(value) {
|
|
|
222
225
|
stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
|
|
223
226
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
224
227
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
228
|
+
deployStackInBuild: cmsRecord.deployStackInBuild,
|
|
225
229
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
226
230
|
prospectusAccountsApiStageArn: optionalString(cmsRecord.prospectusAccountsApiStageArn, "cms.prospectusAccountsApiStageArn", PROSPECTUS_ACCOUNTS_API_STAGE_ARN_PATTERN, "arn:aws:execute-api:<region>:<account>:<api id>/api"),
|
|
227
231
|
};
|
package/infra/site-config.ts
CHANGED
|
@@ -42,6 +42,7 @@ export type CmsConfig = {
|
|
|
42
42
|
stagingDomainPrefix?: string;
|
|
43
43
|
redirects?: CmsRedirect[];
|
|
44
44
|
prospectusAccountsApiStageArn?: string;
|
|
45
|
+
deployStackInBuild?: boolean;
|
|
45
46
|
};
|
|
46
47
|
|
|
47
48
|
export type AmplifyAppShellSiteConfig = {
|
|
@@ -281,7 +282,7 @@ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
|
|
|
281
282
|
|
|
282
283
|
function parseCms(value: unknown): CmsConfig {
|
|
283
284
|
const cmsRecord = requireObject(value, "cms");
|
|
284
|
-
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn"], "cms");
|
|
285
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview", "prospectusAccountsApiStageArn", "deployStackInBuild"], "cms");
|
|
285
286
|
const buildComputeType = cmsRecord.buildComputeType;
|
|
286
287
|
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
287
288
|
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
@@ -292,6 +293,9 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
292
293
|
if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
|
|
293
294
|
fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
|
|
294
295
|
}
|
|
296
|
+
if (cmsRecord.deployStackInBuild !== undefined && typeof cmsRecord.deployStackInBuild !== "boolean") {
|
|
297
|
+
fail("cms.deployStackInBuild", `must be a boolean, got ${JSON.stringify(cmsRecord.deployStackInBuild)}`);
|
|
298
|
+
}
|
|
295
299
|
const cms: CmsConfig = {
|
|
296
300
|
appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
|
|
297
301
|
domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
|
|
@@ -309,6 +313,7 @@ function parseCms(value: unknown): CmsConfig {
|
|
|
309
313
|
stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
|
|
310
314
|
stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
|
|
311
315
|
redirects: parseRedirects(cmsRecord.redirects),
|
|
316
|
+
deployStackInBuild: cmsRecord.deployStackInBuild as boolean | undefined,
|
|
312
317
|
stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
|
|
313
318
|
prospectusAccountsApiStageArn: optionalString(
|
|
314
319
|
cmsRecord.prospectusAccountsApiStageArn,
|
|
@@ -1510,7 +1510,7 @@ export function createDemoCategorySteeringDashboard(
|
|
|
1510
1510
|
assignments: [
|
|
1511
1511
|
{
|
|
1512
1512
|
id: assignmentReportingId,
|
|
1513
|
-
assignmentTypeKey: "reporting.
|
|
1513
|
+
assignmentTypeKey: "reporting.candidate",
|
|
1514
1514
|
queueKey: "edition:edition-demo:section:news:lane:reporting",
|
|
1515
1515
|
queueStatusKey: "edition:edition-demo:section:news:lane:reporting#open",
|
|
1516
1516
|
status: "open",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { EditionContent, EditionSection } from "./content-types";
|
|
2
|
+
import { createEditionSectionPlan, createSectionKey } from "./edition-sections";
|
|
3
|
+
import { getArticlePublicationItems, type ArticlePublicationItem } from "./publication-items";
|
|
4
|
+
|
|
5
|
+
export const ITEM_STREAM_RECENT_EDITION_COUNT = 10;
|
|
6
|
+
export const ITEM_STREAM_ITEM_LIMIT = 50;
|
|
7
|
+
|
|
8
|
+
export type ItemStream = {
|
|
9
|
+
items: ArticlePublicationItem[];
|
|
10
|
+
sections: EditionSection[];
|
|
11
|
+
activeSectionKey: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Flattens published content into one newest-first stream of articles. The
|
|
16
|
+
* input is ordered newest first; a slug that appears twice keeps its newest entry.
|
|
17
|
+
*/
|
|
18
|
+
export function selectNewestStreamItems(
|
|
19
|
+
newestFirstContents: EditionContent[],
|
|
20
|
+
limit: number = ITEM_STREAM_ITEM_LIMIT,
|
|
21
|
+
): ArticlePublicationItem[] {
|
|
22
|
+
const seenSlugs = new Set<string>();
|
|
23
|
+
const selected: ArticlePublicationItem[] = [];
|
|
24
|
+
for (const content of newestFirstContents) {
|
|
25
|
+
for (const item of getArticlePublicationItems(content.items)) {
|
|
26
|
+
if (seenSlugs.has(item.slug)) continue;
|
|
27
|
+
seenSlugs.add(item.slug);
|
|
28
|
+
selected.push(item);
|
|
29
|
+
if (selected.length >= limit) return selected;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return selected;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Builds the stream view: the sections found in the items, and the items filtered by an optional section key. */
|
|
36
|
+
export function buildItemStream(items: ArticlePublicationItem[], sectionKey?: string | null): ItemStream {
|
|
37
|
+
const sections = createEditionSectionPlan(items);
|
|
38
|
+
const activeSectionKey = sectionKey ? createSectionKey(sectionKey) : null;
|
|
39
|
+
const activeSection = activeSectionKey ? sections.find((section) => section.key === activeSectionKey) : undefined;
|
|
40
|
+
if (!activeSection) return { items, sections, activeSectionKey: null };
|
|
41
|
+
const itemIds = new Set(activeSection.itemIds);
|
|
42
|
+
return {
|
|
43
|
+
items: items.filter((item) => itemIds.has(item.slug)),
|
|
44
|
+
sections,
|
|
45
|
+
activeSectionKey: activeSection.key,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -41,7 +41,7 @@ export function formatAssignmentTypeLabel(typeKey: string | null | undefined): s
|
|
|
41
41
|
if (key === "analysis.reindex") return "Analysis re-index";
|
|
42
42
|
if (key === "newsroom.research" || key === "research") return "Research";
|
|
43
43
|
if (key === "curation.reference-intake") return "Reference intake";
|
|
44
|
-
if (key === "reporting.
|
|
44
|
+
if (key === "reporting.candidate") return "Reporting candidate";
|
|
45
45
|
return key
|
|
46
46
|
.split(/[.:_-]+/)
|
|
47
47
|
.filter(Boolean)
|
|
@@ -194,7 +194,7 @@ export function applyPilobolusDemoDashboardContent(
|
|
|
194
194
|
});
|
|
195
195
|
|
|
196
196
|
localized.assignments = localized.assignments.map((assignment, index) => {
|
|
197
|
-
if (assignment.assignmentTypeKey === "reporting.
|
|
197
|
+
if (assignment.assignmentTypeKey === "reporting.candidate") {
|
|
198
198
|
return {
|
|
199
199
|
...assignment,
|
|
200
200
|
title: "Field-notes reporting candidate 1: Strangler-fig control-loop shift",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ContentRepository, EditionContent } from "./content-types";
|
|
2
2
|
import { graphqlContentRepository } from "./graphql-content-repository";
|
|
3
|
-
import { SITE_BRAND } from "./site-brand";
|
|
3
|
+
import { SITE_BRAND, editionsEnabled } from "./site-brand";
|
|
4
4
|
import { getContentSource } from "./site-env";
|
|
5
5
|
import type { RendererConfig } from "./renderer-config";
|
|
6
6
|
|
|
@@ -40,6 +40,7 @@ const RESERVED_DATE_CHILD_SEGMENTS = new Set(["page", "section"]);
|
|
|
40
40
|
type EnumerationRepository = Pick<ContentRepository, "listPublishedEditions" | "loadEditionContent">;
|
|
41
41
|
|
|
42
42
|
function shouldEnumerateAtBuild(): boolean {
|
|
43
|
+
if (!editionsEnabled()) return false;
|
|
43
44
|
if (process.env.NODE_ENV === "development") return false;
|
|
44
45
|
return siteEnumeratesReaderRoutesAtBuild();
|
|
45
46
|
}
|
|
@@ -106,7 +106,7 @@ export type ReportingStoryBudgetInput = {
|
|
|
106
106
|
messagePayloads?: Record<string, unknown>;
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
-
const REPORTING_ASSIGNMENT_TYPE = "reporting.
|
|
109
|
+
const REPORTING_ASSIGNMENT_TYPE = "reporting.candidate";
|
|
110
110
|
const REPORTING_PACKET_KIND = "reporting_context_packet";
|
|
111
111
|
const RESEARCH_PACKET_KIND = "research_packet";
|
|
112
112
|
|
|
@@ -331,7 +331,7 @@ function buildResearchPacketIdsByReportingAssignment(assignments: AssignmentReco
|
|
|
331
331
|
if (relation.subjectKind === "assignment" && relation.objectKind === "assignment") {
|
|
332
332
|
const reporting = assignmentById.get(relation.subjectId);
|
|
333
333
|
const research = assignmentById.get(relation.objectId);
|
|
334
|
-
if (reporting?.assignmentTypeKey !== REPORTING_ASSIGNMENT_TYPE || research?.assignmentTypeKey !== "research.
|
|
334
|
+
if (reporting?.assignmentTypeKey !== REPORTING_ASSIGNMENT_TYPE || research?.assignmentTypeKey !== "research.candidate") continue;
|
|
335
335
|
for (const packetId of producedResearchPacketsByAssignment.get(research.id) ?? []) {
|
|
336
336
|
result.set(reporting.id, addToSet(result.get(reporting.id), packetId));
|
|
337
337
|
}
|
package/lib/site-brand.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isLocalPreviewEnabled } from "./local-preview";
|
|
2
|
+
import { areEditionsEnabled } from "./site-env";
|
|
2
3
|
import type { ComponentType } from "react";
|
|
3
4
|
import type { EditionPresentationFormat } from "./content-types";
|
|
4
5
|
import type { Article, ArticleVideoAsset } from "./articles";
|
|
@@ -157,6 +158,11 @@ export type SiteBrand = {
|
|
|
157
158
|
fonts?: SiteBrandFont[];
|
|
158
159
|
demoEdition?: SiteBrandDemoEdition;
|
|
159
160
|
video?: SiteBrandVideo;
|
|
161
|
+
/**
|
|
162
|
+
* Editions are an optional setting, off unless `{ enabled: true }`. When off the
|
|
163
|
+
* publication is a plain stream of items: no edition date routes, archive or masthead date.
|
|
164
|
+
*/
|
|
165
|
+
editions?: { enabled: boolean };
|
|
160
166
|
};
|
|
161
167
|
|
|
162
168
|
const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
|
|
@@ -253,6 +259,11 @@ export function getRootRoute(brand: SiteBrand = SITE_BRAND): RootRouteConfig {
|
|
|
253
259
|
|
|
254
260
|
export const rootRoute = getRootRoute();
|
|
255
261
|
|
|
262
|
+
/** The brand setting wins when present; otherwise the PAPYRUS_EDITIONS_ENABLED switch decides. */
|
|
263
|
+
export function editionsEnabled(brand: Pick<SiteBrand, "editions"> = SITE_BRAND): boolean {
|
|
264
|
+
return brand.editions?.enabled ?? areEditionsEnabled();
|
|
265
|
+
}
|
|
266
|
+
|
|
256
267
|
export function enforcePresentation(presentation: EditionPresentationFormat): EditionPresentationFormat {
|
|
257
268
|
return SITE_BRAND.forcedPresentation ?? presentation;
|
|
258
269
|
}
|
package/lib/site-env.ts
CHANGED
|
@@ -20,6 +20,11 @@ export function getSiteEnv(environment: EnvironmentVariables = process.env): Sit
|
|
|
20
20
|
return configured as SiteEnv;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export function areEditionsEnabled(environment: EnvironmentVariables = process.env): boolean {
|
|
24
|
+
const configured = (environment.PAPYRUS_EDITIONS_ENABLED ?? "").trim().toLowerCase();
|
|
25
|
+
return configured === "true" || configured === "1" || configured === "yes" || configured === "on";
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
export function getContentSource(environment: EnvironmentVariables = process.env): ContentSource {
|
|
24
29
|
const configured = (environment.PAPYRUS_CONTENT_SOURCE ?? "").trim();
|
|
25
30
|
if (!configured) return "published";
|
package/package.json
CHANGED
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"./components/blog-page-background": "./components/blog-page-background.tsx",
|
|
79
79
|
"./components/curation-delegation-client": "./components/curation-delegation-client.ts",
|
|
80
80
|
"./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
|
|
81
|
+
"./components/item-stream-view": "./components/item-stream-view.tsx",
|
|
81
82
|
"./components/markus-ir-preview": "./components/markus-ir-preview.tsx",
|
|
82
83
|
"./components/news-desk-client-provider": "./components/news-desk-client-provider.tsx",
|
|
83
84
|
"./components/news-desk-page": "./components/news-desk-page.tsx",
|
|
@@ -191,6 +192,7 @@
|
|
|
191
192
|
"./lib/furniture": "./lib/furniture.ts",
|
|
192
193
|
"./lib/graphql-content-repository": "./lib/graphql-content-repository.ts",
|
|
193
194
|
"./lib/image-url": "./lib/image-url.ts",
|
|
195
|
+
"./lib/item-stream": "./lib/item-stream.ts",
|
|
194
196
|
"./lib/labels": "./lib/labels.ts",
|
|
195
197
|
"./lib/layout-config": "./lib/layout-config.ts",
|
|
196
198
|
"./lib/layout-plan": "./lib/layout-plan.ts",
|
|
@@ -377,7 +379,7 @@
|
|
|
377
379
|
"optional": true
|
|
378
380
|
}
|
|
379
381
|
},
|
|
380
|
-
"version": "1.
|
|
382
|
+
"version": "1.18.0-next.1",
|
|
381
383
|
"dependencies": {
|
|
382
384
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
383
385
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED