@anthusai/papyrus 1.0.0-next.2 → 1.0.0-next.20

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.
Files changed (139) hide show
  1. package/amplify/auth/resource.js +17 -4
  2. package/amplify/auth/resource.ts +21 -4
  3. package/amplify/data/resource.js +52 -70
  4. package/amplify/data/resource.ts +57 -70
  5. package/amplify/data/schema.js +52 -58
  6. package/amplify/data/schema.ts +57 -58
  7. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  8. package/amplify/functions/console-chat-responder/resource.js +0 -7
  9. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  10. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  11. package/amplify/functions/content-actions/handler.py +149 -0
  12. package/amplify/functions/content-actions/requirements.txt +4 -0
  13. package/amplify/functions/content-actions/resource.js +40 -0
  14. package/amplify/functions/content-actions/resource.ts +54 -0
  15. package/amplify/functions/shared/python-bundle.js +17 -2
  16. package/amplify/functions/shared/python-bundle.ts +23 -2
  17. package/amplify/rebuild-trigger-settings.js +22 -0
  18. package/amplify/rebuild-trigger-settings.ts +42 -0
  19. package/amplify/site-backend-config.ts +12 -1
  20. package/amplify/site-backend.js +51 -27
  21. package/amplify/site-backend.ts +66 -34
  22. package/amplify/storage/resource.js +3 -0
  23. package/amplify/storage/resource.ts +3 -0
  24. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  25. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +4 -2
  26. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  27. package/app/api/revalidate/route.ts +8 -7
  28. package/app/articles/[slug]/page.tsx +3 -1
  29. package/app/blog-rhythm.css +278 -0
  30. package/app/globals.css +22 -4
  31. package/app/layout.tsx +24 -12
  32. package/app/newsroom/articles/[id]/page.tsx +15 -0
  33. package/app/newsroom/articles/page.tsx +13 -0
  34. package/app/page.tsx +12 -4
  35. package/app/robots.ts +11 -0
  36. package/bin/papyrus-app.mjs +12 -2
  37. package/bin/papyrus-infra.mjs +74 -18
  38. package/components/archive-shell.tsx +2 -22
  39. package/components/article-video.tsx +247 -3
  40. package/components/blog-page-background.tsx +23 -6
  41. package/components/markus-ir-preview.tsx +300 -0
  42. package/components/news-desk-page.tsx +1 -4
  43. package/components/newspaper.tsx +2 -1
  44. package/components/newsroom-access-gate.tsx +66 -0
  45. package/components/newsroom-article-editor.tsx +741 -0
  46. package/components/newsroom-articles-view.tsx +129 -0
  47. package/components/newsroom-ops-overview.tsx +28 -11
  48. package/components/newsroom-ops-shell.tsx +3 -1
  49. package/components/pictogram-figure.tsx +9 -2
  50. package/components/presentation-header.tsx +135 -0
  51. package/components/presentation-rhythm-hrule.tsx +17 -0
  52. package/components/settings-page.tsx +2 -1
  53. package/components/staging-banner.tsx +11 -0
  54. package/components/topic-steering-workspace.tsx +1 -59
  55. package/components/use-blog-header-obstacles.ts +81 -0
  56. package/components/use-brand-fonts-ready.ts +31 -0
  57. package/components/use-rhythm-overlay.ts +29 -0
  58. package/infra/amplify-app-shell.js +285 -80
  59. package/infra/amplify-app-shell.ts +326 -113
  60. package/infra/build-specs.js +94 -0
  61. package/infra/build-specs.ts +141 -0
  62. package/infra/github-oidc-provider.js +24 -0
  63. package/infra/github-oidc-provider.ts +30 -0
  64. package/infra/index.js +3 -0
  65. package/infra/site-config.js +273 -0
  66. package/infra/site-config.ts +336 -0
  67. package/lib/blog-feature-solver.ts +459 -0
  68. package/lib/blog-rhythm.ts +134 -0
  69. package/lib/brand-fonts.ts +112 -0
  70. package/lib/cached-content-repository.ts +2 -1
  71. package/lib/category-repository.ts +1 -93
  72. package/lib/content-actions-client.ts +98 -0
  73. package/lib/content-actions-demo.ts +190 -0
  74. package/lib/content-repository.ts +3 -0
  75. package/lib/content-source-context.ts +26 -0
  76. package/lib/content-types.ts +3 -0
  77. package/lib/edition-routes.ts +31 -29
  78. package/lib/empty-theme.css +1 -0
  79. package/lib/graphql-content-repository.ts +201 -42
  80. package/lib/layout-scenarios.ts +8 -8
  81. package/lib/newspaper-layout.ts +3 -2
  82. package/lib/newsroom-articles.ts +325 -0
  83. package/lib/newsroom-demo-articles.ts +90 -0
  84. package/lib/newsroom-media-backend.ts +142 -0
  85. package/lib/newsroom-media.ts +182 -0
  86. package/lib/newsroom-nav.ts +3 -0
  87. package/lib/papyrus-web-locations.ts +7 -6
  88. package/lib/presentation-footer.ts +3 -2
  89. package/lib/pretext-layout.ts +7 -3
  90. package/lib/reader-base-path.ts +24 -0
  91. package/lib/reader-static-params.ts +8 -0
  92. package/lib/renderer.ts +7 -0
  93. package/lib/revalidate-secret.ts +59 -0
  94. package/lib/root-route-redirect.ts +16 -0
  95. package/lib/site-brand-tenant.ts +3 -22
  96. package/lib/site-brand.ts +95 -42
  97. package/lib/site-env.ts +52 -0
  98. package/lib/staging-gate.ts +37 -0
  99. package/lib/staging-gated-path.ts +22 -0
  100. package/lib/staging-preview-object.ts +98 -0
  101. package/lib/video-mode.ts +42 -0
  102. package/lib/video-script.ts +63 -0
  103. package/middleware.ts +56 -42
  104. package/package.json +39 -1
  105. package/renderers/pretext/article-page.tsx +40 -9
  106. package/renderers/pretext/presentation-shared.tsx +113 -0
  107. package/renderers/pretext/presentation-shell.tsx +18 -112
  108. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  109. package/renderers/pretext/rhythm-blog-presentation.tsx +280 -0
  110. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  111. package/routes.manifest.json +37 -2
  112. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  113. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  114. package/scripts/videoml/bundle-common.mjs +129 -0
  115. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  116. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  117. package/scripts/videoml/preview/preview-timing.ts +42 -0
  118. package/scripts/videoml/preview.html +77 -0
  119. package/scripts/videoml/standard-entry.tsx +46 -0
  120. package/src/sync.mjs +30 -6
  121. package/src/with-papyrus.mjs +15 -2
  122. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  123. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  124. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
  125. package/publications/anth_us/brand.ts +0 -68
  126. package/publications/anth_us/theme.css +0 -66
  127. package/publications/pilobol_us/brand.ts +0 -37
  128. package/publications/pilobol_us/theme.css +0 -140
  129. package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
  130. package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
  131. package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
  132. package/publications/threat_intelligence/brand.ts +0 -40
  133. package/publications/threat_intelligence/pictograms/art.tsx +0 -608
  134. package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
  135. package/publications/threat_intelligence/pictograms/registry.ts +0 -30
  136. package/publications/threat_intelligence/pictograms/system.tsx +0 -305
  137. package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
  138. package/publications/threat_intelligence/theme.css +0 -1440
  139. /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
@@ -10,6 +10,7 @@ import type {
10
10
  } from "./content-types";
11
11
  import { graphqlContentRepository } from "./graphql-content-repository";
12
12
  import { READER_REVALIDATE_SECONDS } from "./reader-route-config";
13
+ import { getContentSource } from "./site-env";
13
14
 
14
15
  const bypassReaderCacheInDevelopment = process.env.NODE_ENV === "development";
15
16
 
@@ -34,7 +35,7 @@ function withReaderCache<T>(
34
35
  tags: string[],
35
36
  loader: () => Promise<T>,
36
37
  ): Promise<T> {
37
- if (bypassReaderCacheInDevelopment) return loader();
38
+ if (bypassReaderCacheInDevelopment || getContentSource() === "drafts") return loader();
38
39
  return unstable_cache(loader, cacheKey, {
39
40
  tags,
40
41
  revalidate: READER_REVALIDATE_SECONDS,
@@ -769,85 +769,7 @@ function knowledgeCorpusIdFromKey(corpusKey: string): string {
769
769
  }
770
770
 
771
771
  export async function loadCategorySteeringDashboard(): Promise<CategorySteeringDashboard> {
772
- const proposals = await loadServerSteeringProposals();
773
- if (proposals.length === 0) return createEmptyCategorySteeringDashboard();
774
- return {
775
- ...createEmptyCategorySteeringDashboard(),
776
- isPublicSkeleton: false,
777
- proposals: sortProposals(proposals),
778
- loadError: null,
779
- };
780
- }
781
-
782
- const SERVER_STEERING_PROPOSALS_QUERY = `
783
- query ListSteeringProposals($limit: Int, $nextToken: String) {
784
- listSteeringProposals(limit: $limit, nextToken: $nextToken) {
785
- items {
786
- id
787
- categorySetId
788
- corpusId
789
- proposalKind
790
- steeringDomain
791
- status
792
- title
793
- summary
794
- categoryKey
795
- targetCategoryKey
796
- graphEntityId
797
- relationshipType
798
- displayName
799
- shortTitle
800
- subtitle
801
- description
802
- evidenceItemIds
803
- suggestedSeedItemIds
804
- suggestedHoldoutItemIds
805
- sourceSnapshotId
806
- proposedAt
807
- reviewedAt
808
- reviewedBy
809
- updatedAt
810
- }
811
- nextToken
812
- }
813
- }
814
- `;
815
-
816
- async function loadServerSteeringProposals(): Promise<CategorySteeringProposal[]> {
817
- const endpoint = process.env.PAPYRUS_GRAPHQL_ENDPOINT;
818
- const jwt = process.env.PAPYRUS_GRAPHQL_JWT;
819
- if (!endpoint || !jwt) return [];
820
-
821
- const items: CategorySteeringProposal[] = [];
822
- let nextToken: string | null = null;
823
-
824
- for (let page = 0; page < 20; page += 1) {
825
- const response = await fetch(endpoint, {
826
- method: "POST",
827
- headers: {
828
- "content-type": "application/json",
829
- Authorization: `Bearer ${jwt}`,
830
- },
831
- body: JSON.stringify({
832
- query: SERVER_STEERING_PROPOSALS_QUERY,
833
- variables: { limit: 500, nextToken },
834
- }),
835
- cache: "no-store",
836
- });
837
- if (!response.ok) return [];
838
- const payload = await response.json() as {
839
- data?: { listSteeringProposals?: { items?: Array<CategorySteeringProposal | null> | null; nextToken?: string | null } | null } | null;
840
- errors?: Array<{ message?: string }> | null;
841
- };
842
- if (payload.errors?.length) return [];
843
- const connection = payload.data?.listSteeringProposals;
844
- if (!connection) return [];
845
- items.push(...(connection.items ?? []).filter(Boolean) as CategorySteeringProposal[]);
846
- nextToken = connection.nextToken ?? null;
847
- if (!nextToken) break;
848
- }
849
-
850
- return items;
772
+ return createEmptyCategorySteeringDashboard();
851
773
  }
852
774
 
853
775
  function sortCategorySets(categorySets: CategorySteeringCategorySet[], importRuns: CategorySteeringImportRun[]): CategorySteeringCategorySet[] {
@@ -895,20 +817,6 @@ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringC
895
817
  });
896
818
  }
897
819
 
898
- function sortProposals(proposals: CategorySteeringProposal[]): CategorySteeringProposal[] {
899
- const statusWeight = new Map([
900
- ["proposed", 0],
901
- ["deferred", 1],
902
- ["accepted", 2],
903
- ["rejected", 3],
904
- ]);
905
- return [...proposals].sort((left, right) => {
906
- const statusDiff = (statusWeight.get(left.status) ?? 9) - (statusWeight.get(right.status) ?? 9);
907
- if (statusDiff !== 0) return statusDiff;
908
- return (right.proposedAt ?? right.updatedAt ?? "").localeCompare(left.proposedAt ?? left.updatedAt ?? "");
909
- });
910
- }
911
-
912
820
  function defaultNewsroomSections(importedAt: string, sectionsConfigPath = DEFAULT_NEWSROOM_SECTIONS_CONFIG_PATH): NewsroomSectionRecord[] {
913
821
  return loadNewsroomSectionSeedRows(sectionsConfigPath).map((row, index) => ({
914
822
  ...row,
@@ -0,0 +1,98 @@
1
+ import { generateClient } from "aws-amplify/data";
2
+ import type { Schema } from "../amplify/data/resource";
3
+
4
+ export type ContentActionError = { code: string; message: string; line: number | null };
5
+
6
+ export type DeriveMarkusInput = { frontMatterYaml: string | null; bodyMarkus: string; includeIr?: boolean };
7
+ export type SaveItemDraftInput = {
8
+ id: string | null;
9
+ type: string;
10
+ slug: string;
11
+ section: string | null;
12
+ frontMatterYaml: string | null;
13
+ bodyMarkus: string;
14
+ aliases: string[];
15
+ expectedContentHash: string | null;
16
+ };
17
+
18
+ export type DeriveMarkusResult = { ok: true; bodyIrBytes: number; bodyIr?: unknown; errors: [] };
19
+ export type SavedItemSummary = {
20
+ id: string;
21
+ contentHash: string;
22
+ status: string;
23
+ slug: string;
24
+ versionNumber: number | null;
25
+ };
26
+ export type SaveItemDraftResult = { ok: true; item: SavedItemSummary };
27
+ export type PublishItemResult = { ok: true; changed: boolean; publishedId: string; versionNumber: number | null };
28
+ export type UnpublishItemResult = { ok: true; changed: boolean };
29
+
30
+ export class ContentActionFailure extends Error {
31
+ readonly errors: ContentActionError[];
32
+
33
+ constructor(errors: ContentActionError[]) {
34
+ super(errors.map((error) => error.message).join("; ") || "Content action failed");
35
+ this.name = "ContentActionFailure";
36
+ this.errors = errors;
37
+ }
38
+ }
39
+
40
+ type GraphqlEnvelope = { data?: unknown; errors?: { message?: string }[] | null };
41
+
42
+ export type ContentActionsTransport = {
43
+ queries: { deriveMarkus: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope> };
44
+ mutations: {
45
+ saveItemDraft: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
46
+ publishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
47
+ unpublishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
48
+ };
49
+ };
50
+
51
+ const USER_POOL = { authMode: "userPool" } as const;
52
+
53
+ function parseActionResponse<T>(envelope: GraphqlEnvelope): T {
54
+ if (envelope.errors && envelope.errors.length > 0) {
55
+ throw new Error(envelope.errors.map((error) => error.message ?? "GraphQL error").join("; "));
56
+ }
57
+ const raw = envelope.data;
58
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
59
+ if (!parsed || typeof parsed !== "object") {
60
+ throw new Error("The content action returned no data.");
61
+ }
62
+ const response = parsed as { ok?: boolean; errors?: ContentActionError[] };
63
+ if (response.ok !== true) {
64
+ throw new ContentActionFailure(response.errors ?? []);
65
+ }
66
+ return parsed as T;
67
+ }
68
+
69
+ export function createContentActionsClient(transport: ContentActionsTransport) {
70
+ return {
71
+ async deriveMarkus(input: DeriveMarkusInput): Promise<DeriveMarkusResult> {
72
+ return parseActionResponse(await transport.queries.deriveMarkus({ input: JSON.stringify(input) }, USER_POOL));
73
+ },
74
+ async saveItemDraft(input: SaveItemDraftInput): Promise<SaveItemDraftResult> {
75
+ return parseActionResponse(await transport.mutations.saveItemDraft({ input: JSON.stringify(input) }, USER_POOL));
76
+ },
77
+ async publishItem(id: string): Promise<PublishItemResult> {
78
+ return parseActionResponse(await transport.mutations.publishItem({ input: JSON.stringify({ id }) }, USER_POOL));
79
+ },
80
+ async unpublishItem(id: string): Promise<UnpublishItemResult> {
81
+ return parseActionResponse(await transport.mutations.unpublishItem({ input: JSON.stringify({ id }) }, USER_POOL));
82
+ },
83
+ };
84
+ }
85
+
86
+ let defaultClient: ReturnType<typeof createContentActionsClient> | null = null;
87
+
88
+ function contentActionsClient() {
89
+ if (!defaultClient) {
90
+ defaultClient = createContentActionsClient(generateClient<Schema>() as unknown as ContentActionsTransport);
91
+ }
92
+ return defaultClient;
93
+ }
94
+
95
+ export const deriveMarkus = (input: DeriveMarkusInput) => contentActionsClient().deriveMarkus(input);
96
+ export const saveItemDraft = (input: SaveItemDraftInput) => contentActionsClient().saveItemDraft(input);
97
+ export const publishItem = (id: string) => contentActionsClient().publishItem(id);
98
+ export const unpublishItem = (id: string) => contentActionsClient().unpublishItem(id);
@@ -0,0 +1,190 @@
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
+ const DEMO_IMAGE_DIRECTIVE = /^::image\{([^}]*)\}$/;
26
+
27
+ function demoAttribute(attributes: string, name: string): string {
28
+ const match = new RegExp(`${name}="([^"]*)"`).exec(attributes);
29
+ return match ? match[1] : "";
30
+ }
31
+
32
+ function demoEnvelope(bodyMarkus: string) {
33
+ const images: Record<string, Record<string, unknown>> = {};
34
+ const children = bodyMarkus
35
+ .split(/\n{2,}/)
36
+ .map((block) => block.trim())
37
+ .filter(Boolean)
38
+ .map((block) => {
39
+ const heading = /^(#{1,6})\s+(.*)$/.exec(block);
40
+ if (heading) {
41
+ return { type: "heading", level: heading[1].length, inline: [{ type: "text", text: heading[2] }], line: null };
42
+ }
43
+ const image = DEMO_IMAGE_DIRECTIVE.exec(block);
44
+ if (image) {
45
+ const token = `PAPYRUSMARKUP${String(Object.keys(images).length + 1).padStart(5, "0")}END`;
46
+ images[token] = {
47
+ src: demoAttribute(image[1], "src"),
48
+ alt: demoAttribute(image[1], "alt"),
49
+ layout: demoAttribute(image[1], "layout") || null,
50
+ };
51
+ return { type: "paragraph", inline: [{ type: "text", text: token }], line: null };
52
+ }
53
+ return { type: "paragraph", inline: [{ type: "text", text: block }], line: null };
54
+ });
55
+ return {
56
+ schemaVersion: 1,
57
+ markus: { version: "demo", irSchemaVersion: 1 },
58
+ document: { type: "document", schema_version: 1, front_matter: {}, children },
59
+ papyrus: { images, citations: {}, citationLists: {}, entries: {}, bibliography: [] },
60
+ };
61
+ }
62
+
63
+ export function createDemoArticlesBackend(): ArticlesBackend {
64
+ const records = new Map<string, DemoArticleRecord>(createDemoArticleRecords().map((record) => [record.id, record]));
65
+ let created = 0;
66
+
67
+ const requireRecord = (id: string): DemoArticleRecord => {
68
+ const record = records.get(id);
69
+ if (!record) throw new ContentActionFailure([{ code: "not-found", message: "That article was not found.", line: null }]);
70
+ return record;
71
+ };
72
+
73
+ return {
74
+ actions: {
75
+ async deriveMarkus(input: DeriveMarkusInput): Promise<DeriveMarkusResult> {
76
+ const lines = input.bodyMarkus.split("\n");
77
+ const invalidIndex = lines.findIndex((line) => line.includes(":::nope"));
78
+ if (invalidIndex >= 0) {
79
+ throw new ContentActionFailure([
80
+ { code: "markus-validation", message: 'Unknown directive "nope".', line: invalidIndex + 1 + sourceLineOffset(input.frontMatterYaml) },
81
+ ]);
82
+ }
83
+ const envelope = demoEnvelope(input.bodyMarkus);
84
+ return {
85
+ ok: true,
86
+ bodyIrBytes: JSON.stringify(envelope).length,
87
+ ...(input.includeIr ? { bodyIr: envelope } : {}),
88
+ errors: [],
89
+ };
90
+ },
91
+ async saveItemDraft(input: SaveItemDraftInput): Promise<SaveItemDraftResult> {
92
+ const existing = input.id ? requireRecord(input.id) : null;
93
+ if (existing && input.expectedContentHash !== null && input.expectedContentHash !== existing.contentHash) {
94
+ throw new ContentActionFailure([
95
+ { code: "conflict", message: "The item changed since it was loaded.", line: null },
96
+ ]);
97
+ }
98
+ if (existing && existing.everPublished && existing.slug !== input.slug) {
99
+ throw new ContentActionFailure([
100
+ { code: "slug-locked", message: "The slug cannot change once the item has been published.", line: null },
101
+ ]);
102
+ }
103
+ const id = existing?.id ?? `demo-article-new-${(created += 1)}`;
104
+ const title = titleFromYaml(input.frontMatterYaml, input.slug);
105
+ const frontMatterYaml = input.frontMatterYaml ?? "";
106
+ const record: DemoArticleRecord = {
107
+ id,
108
+ type: input.type,
109
+ slug: input.slug,
110
+ section: input.section,
111
+ title,
112
+ status: existing?.status ?? "draft",
113
+ frontMatterYaml,
114
+ bodyMarkus: input.bodyMarkus,
115
+ contentHash: demoContentHash(frontMatterYaml, input.bodyMarkus),
116
+ publishedContentHash: existing?.publishedContentHash ?? null,
117
+ versionNumber: existing?.versionNumber ?? null,
118
+ everPublished: existing?.everPublished ?? false,
119
+ updatedAt: new Date().toISOString(),
120
+ };
121
+ records.set(id, record);
122
+ return {
123
+ ok: true,
124
+ item: {
125
+ id,
126
+ contentHash: record.contentHash,
127
+ status: record.status,
128
+ slug: record.slug,
129
+ versionNumber: record.versionNumber,
130
+ },
131
+ };
132
+ },
133
+ async publishItem(id: string): Promise<PublishItemResult> {
134
+ const record = requireRecord(id);
135
+ const changed = record.status !== "published" || record.publishedContentHash !== record.contentHash;
136
+ const versionNumber = changed ? (record.versionNumber ?? 0) + 1 : record.versionNumber;
137
+ records.set(id, {
138
+ ...record,
139
+ status: "published",
140
+ everPublished: true,
141
+ publishedContentHash: record.contentHash,
142
+ versionNumber,
143
+ updatedAt: new Date().toISOString(),
144
+ });
145
+ return { ok: true, changed, publishedId: `published-${id}`, versionNumber };
146
+ },
147
+ async unpublishItem(id: string): Promise<UnpublishItemResult> {
148
+ const record = requireRecord(id);
149
+ const changed = record.status === "published";
150
+ records.set(id, {
151
+ ...record,
152
+ status: "draft",
153
+ publishedContentHash: null,
154
+ updatedAt: new Date().toISOString(),
155
+ });
156
+ return { ok: true, changed };
157
+ },
158
+ },
159
+ async listRows(): Promise<NewsroomArticleRow[]> {
160
+ return [...records.values()]
161
+ .map((record) => ({
162
+ id: record.id,
163
+ type: record.type,
164
+ slug: record.slug,
165
+ section: record.section,
166
+ title: record.title || record.slug,
167
+ status: record.status,
168
+ pending: record.status === "published" && record.publishedContentHash !== record.contentHash,
169
+ updatedAt: record.updatedAt,
170
+ }))
171
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
172
+ },
173
+ async loadArticle(id: string): Promise<EditableArticle> {
174
+ const record = requireRecord(id);
175
+ return {
176
+ id: record.id,
177
+ type: record.type,
178
+ section: record.section,
179
+ slug: record.slug,
180
+ frontMatterYaml: record.frontMatterYaml,
181
+ bodyMarkus: record.bodyMarkus,
182
+ contentHash: record.contentHash,
183
+ status: record.status,
184
+ versionNumber: record.versionNumber,
185
+ aliases: [],
186
+ everPublished: record.everPublished,
187
+ };
188
+ },
189
+ };
190
+ }
@@ -1,6 +1,9 @@
1
1
  import type { ContentRepository } from "./content-types";
2
2
  import { graphqlContentRepository } from "./graphql-content-repository";
3
3
  import { getLayoutScenario } from "./layout-scenarios";
4
+ import { assertContentSourceMatchesEnv } from "./site-env";
5
+
6
+ assertContentSourceMatchesEnv();
4
7
 
5
8
  export const contentRepository: ContentRepository = {
6
9
  loadEditionContent({ scenarioId, editionDate, editionSlug } = {}) {
@@ -0,0 +1,26 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { getContentSource, type ContentSource } from "./site-env";
3
+
4
+ export type ContentSourceContext = {
5
+ source: ContentSource;
6
+ /** Request-scoped data client (drafts mode: carries the visitor's session cookies). */
7
+ client?: unknown;
8
+ };
9
+
10
+ const contentSourceStorage = new AsyncLocalStorage<ContentSourceContext>();
11
+
12
+ export function runWithContentSource<T>(
13
+ source: ContentSource,
14
+ operation: () => Promise<T>,
15
+ client?: unknown,
16
+ ): Promise<T> {
17
+ return contentSourceStorage.run({ source, client }, operation);
18
+ }
19
+
20
+ export function currentRequestClient<T>(): T | undefined {
21
+ return contentSourceStorage.getStore()?.client as T | undefined;
22
+ }
23
+
24
+ export function currentContentSource(): ContentSource {
25
+ return contentSourceStorage.getStore()?.source ?? getContentSource();
26
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Article, ArticleVideoAsset } from "./articles";
2
2
  import type { EditionLayoutPlan } from "./layout-plan";
3
3
  import type { PublicationItem } from "./publication-items";
4
+ import type { VideoScriptRef } from "./video-script";
4
5
 
5
6
  export type ContentSource = "scenario" | "graphql";
6
7
 
@@ -55,6 +56,8 @@ export type EditionContent = {
55
56
  scenarioId?: string;
56
57
  description?: string;
57
58
  editionVideo?: ArticleVideoAsset | null;
59
+ /** Video scripts by item slug (`"edition-overview"` for the edition video), for the VideoML preview. */
60
+ videoScripts?: Record<string, VideoScriptRef>;
58
61
  };
59
62
 
60
63
  export type EditionRouteSummary = {
@@ -1,3 +1,5 @@
1
+ import { getReaderBasePath, toPublicReaderPath } from "./reader-base-path";
2
+
1
3
  const MONTH_NAMES = [
2
4
  "january",
3
5
  "february",
@@ -39,52 +41,52 @@ type ParsedEditionSectionRoute = ParsedEditionDateRoute & {
39
41
 
40
42
  const RESERVED_DATE_CHILD_SEGMENTS = new Set(["page", "section"]);
41
43
 
42
- export function getEditionDatePath(editionDate: string): string {
44
+ export function getEditionDatePath(editionDate: string, readerBasePath: string = getReaderBasePath()): string {
43
45
  const match = editionDate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
44
- if (!match) return "/";
46
+ if (!match) return toPublicReaderPath("/", readerBasePath);
45
47
 
46
48
  const monthIndex = Number(match[2]) - 1;
47
49
  const monthName = MONTH_NAMES[monthIndex];
48
- if (!monthName) return "/";
50
+ if (!monthName) return toPublicReaderPath("/", readerBasePath);
49
51
 
50
- return `/${match[1]}/${monthName}/${match[3]}`;
52
+ return toPublicReaderPath(`/${match[1]}/${monthName}/${match[3]}`, readerBasePath);
51
53
  }
52
54
 
53
- export function getEditionPagePath(editionDate: string, pageNumber: number): string {
54
- const basePath = getEditionDatePath(editionDate);
55
+ export function getEditionPagePath(editionDate: string, pageNumber: number, readerBasePath: string = getReaderBasePath()): string {
56
+ const basePath = getEditionDatePath(editionDate, readerBasePath);
55
57
  return pageNumber <= 1 ? basePath : `${basePath}/page/${pageNumber}`;
56
58
  }
57
59
 
58
- export function getEditionSectionPath(editionDate: string, sectionKey: string): string {
59
- return `${getEditionDatePath(editionDate)}/section/${encodeURIComponent(sectionKey)}`;
60
+ export function getEditionSectionPath(editionDate: string, sectionKey: string, readerBasePath: string = getReaderBasePath()): string {
61
+ return `${getEditionDatePath(editionDate, readerBasePath)}/section/${encodeURIComponent(sectionKey)}`;
60
62
  }
61
63
 
62
- export function getEditionArticlePath(editionDate: string, articleSlug: string): string {
63
- return `${getEditionDatePath(editionDate)}/${encodeURIComponent(articleSlug)}`;
64
+ export function getEditionArticlePath(editionDate: string, articleSlug: string, readerBasePath: string = getReaderBasePath()): string {
65
+ return `${getEditionDatePath(editionDate, readerBasePath)}/${encodeURIComponent(articleSlug)}`;
64
66
  }
65
67
 
66
68
  export const getEditionItemPath = getEditionArticlePath;
67
69
 
68
- export function parseEditionDateRoute(input: EditionDateRouteInput): ParsedEditionDateRoute | null {
69
- const parsed = parseEditionDateSegments(input);
70
+ export function parseEditionDateRoute(input: EditionDateRouteInput, readerBasePath: string = getReaderBasePath()): ParsedEditionDateRoute | null {
71
+ const parsed = parseEditionDateSegments(input, readerBasePath);
70
72
  if (!parsed) return null;
71
73
 
72
74
  return {
73
75
  ...parsed,
74
- isCanonical: getCurrentDatePath(input) === parsed.canonicalPath,
76
+ isCanonical: getCurrentDatePath(input, readerBasePath) === parsed.canonicalPath,
75
77
  };
76
78
  }
77
79
 
78
- export function parseEditionPageRoute(input: EditionDateRouteInput & { pageNumber: string }): ParsedEditionPageRoute | null {
79
- const parsed = parseEditionDateSegments(input);
80
+ export function parseEditionPageRoute(input: EditionDateRouteInput & { pageNumber: string }, readerBasePath: string = getReaderBasePath()): ParsedEditionPageRoute | null {
81
+ const parsed = parseEditionDateSegments(input, readerBasePath);
80
82
  if (!parsed) return null;
81
83
  if (!/^\d+$/.test(input.pageNumber)) return null;
82
84
 
83
85
  const pageNumber = Number(input.pageNumber);
84
86
  if (!Number.isSafeInteger(pageNumber) || pageNumber < 1) return null;
85
87
 
86
- const canonicalPath = getEditionPagePath(parsed.editionDate, pageNumber);
87
- const currentPath = `${getCurrentDatePath(input)}/page/${input.pageNumber}`;
88
+ const canonicalPath = getEditionPagePath(parsed.editionDate, pageNumber, readerBasePath);
89
+ const currentPath = `${getCurrentDatePath(input, readerBasePath)}/page/${input.pageNumber}`;
88
90
  return {
89
91
  editionDate: parsed.editionDate,
90
92
  canonicalPath,
@@ -93,13 +95,13 @@ export function parseEditionPageRoute(input: EditionDateRouteInput & { pageNumbe
93
95
  };
94
96
  }
95
97
 
96
- export function parseEditionArticleRoute(input: EditionDateRouteInput & { articleSlug: string }): ParsedEditionArticleRoute | null {
97
- const parsed = parseEditionDateSegments(input);
98
+ export function parseEditionArticleRoute(input: EditionDateRouteInput & { articleSlug: string }, readerBasePath: string = getReaderBasePath()): ParsedEditionArticleRoute | null {
99
+ const parsed = parseEditionDateSegments(input, readerBasePath);
98
100
  if (!parsed) return null;
99
101
  if (RESERVED_DATE_CHILD_SEGMENTS.has(input.articleSlug.toLowerCase())) return null;
100
102
 
101
- const canonicalPath = getEditionArticlePath(parsed.editionDate, input.articleSlug);
102
- const currentPath = `${getCurrentDatePath(input)}/${input.articleSlug}`;
103
+ const canonicalPath = getEditionArticlePath(parsed.editionDate, input.articleSlug, readerBasePath);
104
+ const currentPath = `${getCurrentDatePath(input, readerBasePath)}/${input.articleSlug}`;
103
105
  return {
104
106
  editionDate: parsed.editionDate,
105
107
  canonicalPath,
@@ -108,13 +110,13 @@ export function parseEditionArticleRoute(input: EditionDateRouteInput & { articl
108
110
  };
109
111
  }
110
112
 
111
- export function parseEditionSectionRoute(input: EditionDateRouteInput & { sectionKey: string }): ParsedEditionSectionRoute | null {
112
- const parsed = parseEditionDateSegments(input);
113
+ export function parseEditionSectionRoute(input: EditionDateRouteInput & { sectionKey: string }, readerBasePath: string = getReaderBasePath()): ParsedEditionSectionRoute | null {
114
+ const parsed = parseEditionDateSegments(input, readerBasePath);
113
115
  if (!parsed) return null;
114
116
  if (!input.sectionKey.trim()) return null;
115
117
 
116
- const canonicalPath = getEditionSectionPath(parsed.editionDate, input.sectionKey);
117
- const currentPath = `${getCurrentDatePath(input)}/section/${input.sectionKey}`;
118
+ const canonicalPath = getEditionSectionPath(parsed.editionDate, input.sectionKey, readerBasePath);
119
+ const currentPath = `${getCurrentDatePath(input, readerBasePath)}/section/${input.sectionKey}`;
118
120
  return {
119
121
  editionDate: parsed.editionDate,
120
122
  canonicalPath,
@@ -123,7 +125,7 @@ export function parseEditionSectionRoute(input: EditionDateRouteInput & { sectio
123
125
  };
124
126
  }
125
127
 
126
- function parseEditionDateSegments(input: EditionDateRouteInput): Omit<ParsedEditionDateRoute, "isCanonical"> | null {
128
+ function parseEditionDateSegments(input: EditionDateRouteInput, readerBasePath: string): Omit<ParsedEditionDateRoute, "isCanonical"> | null {
127
129
  if (!/^\d{4}$/.test(input.year)) return null;
128
130
 
129
131
  const monthIndex = MONTH_NAMES.indexOf(input.month.toLowerCase() as (typeof MONTH_NAMES)[number]);
@@ -140,12 +142,12 @@ function parseEditionDateSegments(input: EditionDateRouteInput): Omit<ParsedEdit
140
142
 
141
143
  return {
142
144
  editionDate,
143
- canonicalPath: getEditionDatePath(editionDate),
145
+ canonicalPath: getEditionDatePath(editionDate, readerBasePath),
144
146
  };
145
147
  }
146
148
 
147
- function getCurrentDatePath(input: EditionDateRouteInput): string {
148
- return `/${input.year}/${input.month}/${input.day}`;
149
+ function getCurrentDatePath(input: EditionDateRouteInput, readerBasePath: string): string {
150
+ return toPublicReaderPath(`/${input.year}/${input.month}/${input.day}`, readerBasePath);
149
151
  }
150
152
 
151
153
  function isValidIsoDate(editionDate: string): boolean {
@@ -0,0 +1 @@
1
+ /* Default for `papyrus-site-theme` when a publication ships no publication/theme.css. */