@anthusai/papyrus 1.0.0-next.4 → 1.0.0-next.40

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 (147) hide show
  1. package/amplify/auth/redirect-urls.js +54 -0
  2. package/amplify/auth/redirect-urls.ts +66 -0
  3. package/amplify/auth/resource.js +20 -5
  4. package/amplify/auth/resource.ts +27 -5
  5. package/amplify/data/resource.js +28 -70
  6. package/amplify/data/resource.ts +29 -70
  7. package/amplify/data/schema.js +33 -58
  8. package/amplify/data/schema.ts +34 -58
  9. package/amplify/functions/category-action/handler.ts +5 -3
  10. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  11. package/amplify/functions/console-chat-responder/resource.js +0 -7
  12. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  13. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  14. package/amplify/functions/content-actions/handler.py +45 -3
  15. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  16. package/amplify/inbound-email/ses-intake.js +86 -0
  17. package/amplify/inbound-email/ses-intake.ts +110 -0
  18. package/amplify/rebuild-trigger-settings.js +22 -0
  19. package/amplify/rebuild-trigger-settings.ts +42 -0
  20. package/amplify/resolver-waves.js +26 -0
  21. package/amplify/resolver-waves.ts +34 -0
  22. package/amplify/site-backend-config.ts +36 -1
  23. package/amplify/site-backend-names.js +103 -0
  24. package/amplify/site-backend-names.ts +166 -0
  25. package/amplify/site-backend.js +93 -170
  26. package/amplify/site-backend.ts +126 -196
  27. package/amplify/storage/resource.js +3 -0
  28. package/amplify/storage/resource.ts +3 -0
  29. package/amplify/storage-backups/construct.js +31 -0
  30. package/amplify/storage-backups/construct.ts +47 -0
  31. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  32. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
  33. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  34. package/app/api/media/[...path]/route.ts +5 -16
  35. package/app/api/revalidate/route.ts +8 -7
  36. package/app/articles/[slug]/page.tsx +6 -4
  37. package/app/blog-rhythm.css +278 -0
  38. package/app/globals.css +21 -0
  39. package/app/layout.tsx +16 -0
  40. package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
  41. package/app/newsroom/[section]/page.tsx +4 -3
  42. package/app/newsroom/articles/[id]/page.tsx +15 -0
  43. package/app/newsroom/articles/page.tsx +13 -0
  44. package/app/newsroom/layout.tsx +4 -9
  45. package/app/page.tsx +18 -5
  46. package/app/robots.ts +2 -5
  47. package/bin/papyrus-app.mjs +12 -2
  48. package/bin/papyrus-infra.mjs +29 -3
  49. package/components/amplify-client-provider.tsx +4 -22
  50. package/components/archive-shell.tsx +2 -22
  51. package/components/article-video.tsx +247 -3
  52. package/components/blog-page-background.tsx +23 -6
  53. package/components/markus-ir-preview.tsx +300 -0
  54. package/components/news-desk-taxonomy-client.ts +51 -0
  55. package/components/newspaper.tsx +6 -2
  56. package/components/newsroom-access-gate.tsx +67 -0
  57. package/components/newsroom-article-editor.tsx +741 -0
  58. package/components/newsroom-articles-view.tsx +129 -0
  59. package/components/newsroom-client-shell.tsx +12 -0
  60. package/components/newsroom-ops-overview.tsx +28 -11
  61. package/components/newsroom-ops-shell.tsx +3 -1
  62. package/components/newsroom-references-view.tsx +83 -8
  63. package/components/papyrus-console-shell.tsx +4 -2
  64. package/components/pictogram-figure.tsx +9 -2
  65. package/components/presentation-header.tsx +135 -0
  66. package/components/presentation-rhythm-hrule.tsx +17 -0
  67. package/components/reference-cyclotron-status.tsx +109 -0
  68. package/components/reference-relevance-review.tsx +75 -0
  69. package/components/settings-page.tsx +2 -1
  70. package/components/topic-steering-workspace.tsx +54 -64
  71. package/components/use-blog-header-obstacles.ts +81 -0
  72. package/components/use-brand-fonts-ready.ts +28 -0
  73. package/components/use-rhythm-overlay.ts +29 -0
  74. package/infra/amplify-app-shell.js +212 -50
  75. package/infra/amplify-app-shell.ts +240 -53
  76. package/infra/build-specs.js +14 -5
  77. package/infra/build-specs.ts +17 -7
  78. package/infra/github-oidc-provider.js +24 -0
  79. package/infra/github-oidc-provider.ts +30 -0
  80. package/infra/index.js +1 -0
  81. package/infra/site-config.js +167 -10
  82. package/infra/site-config.ts +196 -14
  83. package/lib/amplify-outputs-path.ts +5 -1
  84. package/lib/blog-feature-solver.ts +459 -0
  85. package/lib/blog-rhythm.ts +134 -0
  86. package/lib/brand-fonts-watch.ts +43 -0
  87. package/lib/brand-fonts.ts +112 -0
  88. package/lib/cached-content-repository.ts +12 -0
  89. package/lib/category-repository.ts +42 -93
  90. package/lib/content-actions-demo.ts +190 -0
  91. package/lib/content-repository.ts +3 -0
  92. package/lib/content-source-context.ts +12 -2
  93. package/lib/content-types.ts +4 -0
  94. package/lib/edition-routes.ts +31 -29
  95. package/lib/graphql-content-repository.ts +61 -9
  96. package/lib/media-route-response.ts +45 -0
  97. package/lib/newspaper-layout.ts +3 -2
  98. package/lib/newsroom-articles.ts +325 -0
  99. package/lib/newsroom-base-path.ts +12 -3
  100. package/lib/newsroom-category-drilldown.ts +3 -2
  101. package/lib/newsroom-demo-articles.ts +90 -0
  102. package/lib/newsroom-forum-routes.ts +6 -4
  103. package/lib/newsroom-index-filters.ts +14 -10
  104. package/lib/newsroom-media-backend.ts +142 -0
  105. package/lib/newsroom-media.ts +182 -0
  106. package/lib/newsroom-nav.ts +3 -0
  107. package/lib/oauth-redirect-priority.ts +40 -0
  108. package/lib/papyrus-web-locations.ts +7 -6
  109. package/lib/presentation-footer.ts +3 -2
  110. package/lib/pretext-layout.ts +12 -3
  111. package/lib/reader-base-path.ts +24 -0
  112. package/lib/reader-static-params.ts +13 -0
  113. package/lib/reference-policy.ts +5 -0
  114. package/lib/relevance-decisions.ts +103 -0
  115. package/lib/relevance-status.ts +63 -0
  116. package/lib/renderer.ts +7 -0
  117. package/lib/revalidate-secret.ts +59 -0
  118. package/lib/robots-policy.ts +14 -0
  119. package/lib/root-route-redirect.ts +16 -0
  120. package/lib/semantic-graph.ts +7 -0
  121. package/lib/site-brand.ts +51 -0
  122. package/lib/staging-gated-path.ts +23 -0
  123. package/lib/staging-preview-object.ts +114 -0
  124. package/lib/video-mode.ts +42 -0
  125. package/lib/video-script.ts +77 -0
  126. package/middleware.ts +23 -18
  127. package/package.json +45 -1
  128. package/renderers/pretext/article-page.tsx +39 -3
  129. package/renderers/pretext/presentation-shared.tsx +113 -0
  130. package/renderers/pretext/presentation-shell.tsx +15 -102
  131. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  132. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  133. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  134. package/routes.manifest.json +45 -2
  135. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  136. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  137. package/scripts/videoml/bundle-common.mjs +129 -0
  138. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  139. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  140. package/scripts/videoml/preview/preview-timing.ts +42 -0
  141. package/scripts/videoml/preview.html +77 -0
  142. package/scripts/videoml/standard-entry.tsx +46 -0
  143. package/src/sync.mjs +30 -6
  144. package/src/with-papyrus.mjs +19 -2
  145. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  146. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  147. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -1,3 +1,4 @@
1
+ import { generateServerClientUsingCookies } from "@aws-amplify/adapter-nextjs/api";
1
2
  import { generateClient } from "aws-amplify/data";
2
3
  import type { Schema } from "../amplify/data/resource";
3
4
  import type { Article, ArticleImage, ArticleImageAsset, ArticleImageLayout, ArticleImageThemeVariants, ArticleVideoAsset, ArticleVideoThemeVariants } from "./articles";
@@ -17,7 +18,9 @@ import {
17
18
  } from "./publication-items";
18
19
  import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type BodyProjectionImage } from "./markus-body";
19
20
  import { SITE_BRAND } from "./site-brand";
20
- import { currentContentSource, runWithContentSource } from "./content-source-context";
21
+ import { shouldFetchVideoScripts } from "./video-mode";
22
+ import { collectVideoScriptTargets, parseVideoScriptRef, videomlItemSlug, type VideoScriptRef } from "./video-script";
23
+ import { currentContentSource, currentRequestClient, runWithContentSource } from "./content-source-context";
21
24
  import { getContentSource } from "./site-env";
22
25
 
23
26
  const DEFAULT_EDITION_SLUG = "current";
@@ -143,12 +146,19 @@ function isVisibleItemStatus(status: string): boolean {
143
146
  }
144
147
 
145
148
  async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise<T> {
146
- const { runWithAmplifyServerContext } = getAmplifyServerRuntime();
149
+ const { runWithAmplifyServerContext, config } = getAmplifyServerRuntime();
147
150
  const source = getContentSource();
148
- const nextServerContext = source === "drafts" ? { cookies: (await import("next/headers")).cookies } : null;
151
+ if (source === "drafts") {
152
+ // Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
153
+ // kept in the request's async context (never a module global) so one visitor's session cannot serve another.
154
+ const { cookies } = await import("next/headers");
155
+ await cookies();
156
+ const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
157
+ return runWithContentSource(source, operation, client);
158
+ }
149
159
  return runWithContentSource(source, () =>
150
160
  runWithAmplifyServerContext({
151
- nextServerContext,
161
+ nextServerContext: null,
152
162
  operation: async () => {
153
163
  readerContextClient = generateClient<Schema>({ authMode: authMode() });
154
164
  try {
@@ -229,6 +239,10 @@ export const graphqlContentRepository: ContentRepository = {
229
239
  return withReaderGraphQLContext(() => loadEditionItem(editionDate, itemSlug));
230
240
  },
231
241
 
242
+ loadVideoScript(targetSlug) {
243
+ return withReaderGraphQLContext(() => loadVideoScriptForTarget(targetSlug));
244
+ },
245
+
232
246
  listArticleSlugs() {
233
247
  return withReaderGraphQLContext(async () => {
234
248
  const items = await listItemsByTypeStatus(`${ARTICLE_TYPE}#${PUBLISHED_STATUS}`);
@@ -241,6 +255,8 @@ export const graphqlContentRepository: ContentRepository = {
241
255
  };
242
256
 
243
257
  function getClient(): DataClient {
258
+ const requestClient = currentRequestClient<DataClient>();
259
+ if (requestClient) return requestClient;
244
260
  if (readerContextClient) return readerContextClient;
245
261
 
246
262
  if (!buildTimeClient) {
@@ -370,6 +386,9 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
370
386
  const sanitizedLayoutPlan = pruneLayoutPlanUnavailableItems(layoutPlan, availableItemSlugs, edition.id);
371
387
  validateEditionLayoutPlanForItems(sanitizedLayoutPlan, items, `PublishedEdition(${edition.id}).layoutPlan`);
372
388
 
389
+ const editionVideo = await normalizeEditionVideoAsset(editionMetadata?.editionVideo);
390
+ const videoScripts = await loadEditionVideoScripts(items, editionVideo);
391
+
373
392
  return {
374
393
  id: edition.id,
375
394
  source: "graphql",
@@ -380,10 +399,38 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
380
399
  items,
381
400
  sections: createEditionSectionPlan(items, edition.metadata),
382
401
  suppressNewsDeskAppendix: editionMetadata?.suppressNewsDeskAppendix === true,
383
- editionVideo: await normalizeEditionVideoAsset(editionMetadata?.editionVideo),
402
+ editionVideo,
403
+ videoScripts,
384
404
  };
385
405
  }
386
406
 
407
+ function videoScriptsEnabled(): boolean {
408
+ return Boolean(SITE_BRAND.videoPlayer) && shouldFetchVideoScripts();
409
+ }
410
+
411
+ async function loadVideoScriptForTarget(targetSlug: string): Promise<VideoScriptRef | null> {
412
+ if (!videoScriptsEnabled()) return null;
413
+ const item = await getItemBySlug(videomlItemSlug(targetSlug));
414
+ if (!item || item.type !== "videoml" || !isVisibleItemStatus(item.status)) return null;
415
+ return parseVideoScriptRef(item);
416
+ }
417
+
418
+ async function loadEditionVideoScripts(
419
+ items: PublicationItem[],
420
+ editionVideo: ArticleVideoAsset | null,
421
+ ): Promise<Record<string, VideoScriptRef> | undefined> {
422
+ if (!videoScriptsEnabled()) return undefined;
423
+ const targets = collectVideoScriptTargets(items, editionVideo !== null);
424
+ const entries = await Promise.all(
425
+ targets.map(async (targetSlug) => {
426
+ const script = await loadVideoScriptForTarget(targetSlug);
427
+ return script ? ([targetSlug, script] as const) : null;
428
+ }),
429
+ );
430
+ const videoScripts = Object.fromEntries(entries.filter((entry): entry is [string, VideoScriptRef] => entry !== null));
431
+ return Object.keys(videoScripts).length > 0 ? videoScripts : undefined;
432
+ }
433
+
387
434
  async function normalizeEditionVideoAsset(value: unknown): Promise<ArticleVideoAsset | null> {
388
435
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
389
436
  const record = value as Record<string, unknown>;
@@ -808,7 +855,7 @@ async function normalizeArticle(item: GraphQLItem, mediaAssets: GraphQLMediaAsse
808
855
 
809
856
  function projectPublishedItemBody(item: GraphQLItem): BodyProjection {
810
857
  if (item.bodyIr === null || item.bodyIr === undefined || item.bodyIr === "") {
811
- throw new BodyIrError(`PublishedItem ${item.slug} has no bodyIr; run: papyrus content convert-bodies`);
858
+ throw new BodyIrError(`PublishedItem ${item.slug} has no bodyIr; run: papyrus ops content convert-bodies`);
812
859
  }
813
860
  return projectBodyIr(item.bodyIr);
814
861
  }
@@ -930,15 +977,20 @@ function parseAttachmentPointer(value: unknown): ContentAttachmentPointer | null
930
977
  };
931
978
  }
932
979
 
933
- function readInlineExcerpt(editorial: unknown): string | null {
980
+ export function readInlineExcerpt(editorial: unknown): string | null {
934
981
  const parsed = parseObjectMetadata(editorial);
935
982
  if (!parsed) return null;
936
983
  const direct = parsed.excerpt;
937
984
  if (typeof direct === "string" && direct.trim()) return direct.trim();
985
+ const custom = parsed.customExcerpt;
986
+ if (typeof custom === "string" && custom.trim()) return custom.trim();
938
987
  const newsroom = parsed.newsroom;
939
988
  if (!newsroom || typeof newsroom !== "object" || Array.isArray(newsroom)) return null;
940
- const nestedExcerpt = (newsroom as Record<string, unknown>).excerpt;
941
- return typeof nestedExcerpt === "string" && nestedExcerpt.trim() ? nestedExcerpt.trim() : null;
989
+ const newsroomRecord = newsroom as Record<string, unknown>;
990
+ const nestedExcerpt = newsroomRecord.excerpt;
991
+ if (typeof nestedExcerpt === "string" && nestedExcerpt.trim()) return nestedExcerpt.trim();
992
+ const nestedCustom = newsroomRecord.customExcerpt;
993
+ return typeof nestedCustom === "string" && nestedCustom.trim() ? nestedCustom.trim() : null;
942
994
  }
943
995
 
944
996
  function normalizePublicationItemType(value: string): Exclude<PublicationItemType, "article"> | null {
@@ -0,0 +1,45 @@
1
+ const PROXIED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp", "avif", "svg"]);
2
+
3
+ export type MediaRouteDependencies = {
4
+ signStorageUrl: (storagePath: string) => Promise<string>;
5
+ fetchUpstream: (signedUrl: string) => Promise<Response>;
6
+ };
7
+
8
+ export function isProxiedImagePath(storagePath: string): boolean {
9
+ const lastSegment = storagePath.split("/").pop() ?? "";
10
+ const dotIndex = lastSegment.lastIndexOf(".");
11
+ if (dotIndex < 0) {
12
+ return false;
13
+ }
14
+ return PROXIED_IMAGE_EXTENSIONS.has(lastSegment.slice(dotIndex + 1).toLowerCase());
15
+ }
16
+
17
+ export async function buildMediaRouteResponse(
18
+ storagePath: string,
19
+ dependencies: MediaRouteDependencies,
20
+ ): Promise<Response> {
21
+ const signedUrl = await dependencies.signStorageUrl(storagePath);
22
+
23
+ if (!isProxiedImagePath(storagePath)) {
24
+ return new Response(null, {
25
+ status: 307,
26
+ headers: { location: signedUrl, "cache-control": "private, max-age=300" },
27
+ });
28
+ }
29
+
30
+ const upstream = await dependencies.fetchUpstream(signedUrl);
31
+ if (!upstream.ok) {
32
+ return Response.json(
33
+ { error: `Upstream media fetch failed (${upstream.status}).` },
34
+ { status: upstream.status === 404 ? 404 : 502 },
35
+ );
36
+ }
37
+
38
+ const headers = new Headers();
39
+ const contentType = upstream.headers.get("content-type");
40
+ if (contentType) {
41
+ headers.set("content-type", contentType);
42
+ }
43
+ headers.set("cache-control", "public, max-age=3600, stale-while-revalidate=86400");
44
+ return new Response(upstream.body, { status: 200, headers });
45
+ }
@@ -1,3 +1,4 @@
1
+ import { toPublicReaderPath } from "./reader-base-path";
1
2
  import type { ArticleImageAsset, ArticleImageThemeVariants } from "./articles";
2
3
  import {
3
4
  type ArticleFrameBlockSpec,
@@ -896,9 +897,9 @@ function solveFrontFooter(blocks: SolvedBlock[], config: LayoutConfig, pageNumbe
896
897
  : Math.min(entries.length, Math.max(1, config.columnCount <= 1 ? 1 : config.columnCount <= 3 ? 2 : 4));
897
898
  const sectionRows = entries.length === 0 ? 0 : Math.ceil(entries.length / sectionColumns);
898
899
  const utilityEntries: SolvedFrontFooterUtilityEntry[] = [
899
- { id: "archive", label: "Archive", href: "/archive", disabled: false },
900
+ { id: "archive", label: "Archive", href: toPublicReaderPath("/archive"), disabled: false },
900
901
  { id: "newsDesk", label: "Newsroom", href: "/newsroom", disabled: false },
901
- { id: "settings", label: "Settings", href: "/settings", disabled: false },
902
+ { id: "settings", label: "Settings", href: toPublicReaderPath("/settings"), disabled: false },
902
903
  { id: "login", label: "LOGIN", disabled: true },
903
904
  ];
904
905
  const rowCount = 1 + Math.max(sectionRows, utilityEntries.length);
@@ -0,0 +1,325 @@
1
+ import { generateClient } from "aws-amplify/data";
2
+ import type { Schema } from "../amplify/data/resource";
3
+ import {
4
+ deriveMarkus,
5
+ publishItem,
6
+ saveItemDraft,
7
+ unpublishItem,
8
+ type ContentActionError,
9
+ } from "./content-actions-client";
10
+ import { createDemoArticlesBackend } from "./content-actions-demo";
11
+
12
+ export type ArticleStatus = "draft" | "published";
13
+ export type ArticleType = "article" | "page";
14
+
15
+ export const ARTICLE_TYPES: ArticleType[] = ["article", "page"];
16
+
17
+ export type NewsroomArticleRow = {
18
+ id: string;
19
+ type: string;
20
+ slug: string;
21
+ section: string | null;
22
+ title: string;
23
+ status: ArticleStatus;
24
+ pending: boolean;
25
+ updatedAt: string;
26
+ };
27
+
28
+ export type ArticleItemSummary = {
29
+ id: string;
30
+ lineageId?: string | null;
31
+ type: string;
32
+ slug: string;
33
+ section?: string | null;
34
+ title?: string | null;
35
+ status: string;
36
+ contentHash?: string | null;
37
+ updatedAt?: string | null;
38
+ };
39
+
40
+ export type PublishedItemSummary = {
41
+ id: string;
42
+ itemLineageId?: string | null;
43
+ sourceItemId?: string | null;
44
+ metadata?: unknown;
45
+ };
46
+
47
+ export type NewsroomArticleEditorState = {
48
+ id: string | null;
49
+ type: string;
50
+ section: string;
51
+ slug: string;
52
+ frontMatterYaml: string;
53
+ bodyMarkus: string;
54
+ contentHash: string | null;
55
+ status: ArticleStatus;
56
+ versionNumber: number | null;
57
+ dirty: boolean;
58
+ };
59
+
60
+ export type EditableArticle = {
61
+ id: string;
62
+ type: string;
63
+ section: string | null;
64
+ slug: string;
65
+ frontMatterYaml: string;
66
+ bodyMarkus: string;
67
+ contentHash: string | null;
68
+ status: ArticleStatus;
69
+ versionNumber: number | null;
70
+ aliases: string[];
71
+ everPublished: boolean;
72
+ };
73
+
74
+ export type ArticlesBackend = {
75
+ actions: {
76
+ deriveMarkus: typeof deriveMarkus;
77
+ saveItemDraft: typeof saveItemDraft;
78
+ publishItem: typeof publishItem;
79
+ unpublishItem: typeof unpublishItem;
80
+ };
81
+ listRows: () => Promise<NewsroomArticleRow[]>;
82
+ loadArticle: (id: string) => Promise<EditableArticle>;
83
+ };
84
+
85
+ export function slugify(value: string): string {
86
+ return value
87
+ .normalize("NFKD")
88
+ .replace(/[^\x00-\x7F]/g, "")
89
+ .toLowerCase()
90
+ .replace(/[^a-z0-9]+/g, "-")
91
+ .replace(/^-+|-+$/g, "");
92
+ }
93
+
94
+ export function suggestSlug(frontMatterYaml: string): string {
95
+ for (const line of frontMatterYaml.split("\n")) {
96
+ const match = /^title\s*:\s*(.*)$/.exec(line);
97
+ if (!match) continue;
98
+ let value = match[1].trim();
99
+ if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.endsWith(value[0])) {
100
+ value = value.slice(1, -1);
101
+ }
102
+ return slugify(value);
103
+ }
104
+ return "";
105
+ }
106
+
107
+ export function errorLine(errors: ContentActionError[]): number | null {
108
+ for (const error of errors) {
109
+ if (typeof error.line === "number") return error.line;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ export function sourceLineOffset(frontMatterYaml: string | null): number {
115
+ const yamlText = (frontMatterYaml ?? "").replace(/^\n+|\n+$/g, "");
116
+ if (!yamlText.trim()) return 0;
117
+ return yamlText.split("\n").length + 2;
118
+ }
119
+
120
+ export type ErrorLocation = { field: "frontMatterYaml" | "bodyMarkus"; line: number | null };
121
+
122
+ export function locateError(error: ContentActionError, frontMatterYaml: string | null): ErrorLocation {
123
+ if (error.code === "front-matter") return { field: "frontMatterYaml", line: null };
124
+ if (error.line === null) return { field: "bodyMarkus", line: null };
125
+ const bodyLine = error.line - sourceLineOffset(frontMatterYaml);
126
+ if (bodyLine >= 1) return { field: "bodyMarkus", line: bodyLine };
127
+ return { field: "frontMatterYaml", line: Math.max(error.line - 1, 1) };
128
+ }
129
+
130
+ export function emptyArticle(): NewsroomArticleEditorState {
131
+ return {
132
+ id: null,
133
+ type: "article",
134
+ section: "",
135
+ slug: "",
136
+ frontMatterYaml: "title: \n",
137
+ bodyMarkus: "",
138
+ contentHash: null,
139
+ status: "draft",
140
+ versionNumber: null,
141
+ dirty: false,
142
+ };
143
+ }
144
+
145
+ function parseJsonObject(value: unknown): Record<string, unknown> {
146
+ if (typeof value === "string") {
147
+ try {
148
+ const parsed = JSON.parse(value);
149
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
150
+ } catch {
151
+ return {};
152
+ }
153
+ }
154
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
155
+ }
156
+
157
+ export function hasPendingChanges(item: ArticleItemSummary, published: PublishedItemSummary | null): boolean {
158
+ if (!published) return false;
159
+ return item.contentHash !== parseJsonObject(published.metadata).sourceContentHash;
160
+ }
161
+
162
+ export function articleStatusLabel(item: ArticleItemSummary, published: PublishedItemSummary | null): string {
163
+ if (item.status !== "published") return "Draft";
164
+ return hasPendingChanges(item, published) ? "Published, unpublished changes" : "Published";
165
+ }
166
+
167
+ function rowFromItem(item: ArticleItemSummary, published: PublishedItemSummary | null): NewsroomArticleRow {
168
+ const status: ArticleStatus = item.status === "published" ? "published" : "draft";
169
+ return {
170
+ id: item.id,
171
+ type: item.type,
172
+ slug: item.slug,
173
+ section: item.section ?? null,
174
+ title: item.title || item.slug,
175
+ status,
176
+ pending: status === "published" && hasPendingChanges(item, published),
177
+ updatedAt: item.updatedAt ?? "",
178
+ };
179
+ }
180
+
181
+ export function sortRowsByUpdatedAtDescending(rows: NewsroomArticleRow[]): NewsroomArticleRow[] {
182
+ return [...rows].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
183
+ }
184
+
185
+ export function rowStatusLabel(row: NewsroomArticleRow): string {
186
+ if (row.status === "draft") return "Draft";
187
+ return row.pending ? "Published, unpublished changes" : "Published";
188
+ }
189
+
190
+ type ListPage<T> = { data?: (T | null)[] | null; nextToken?: string | null; errors?: { message?: string }[] | null };
191
+ type ListOptions = { authMode: "userPool"; nextToken?: string | null; limit?: number };
192
+
193
+ export type ArticlesDataClient = {
194
+ models: {
195
+ Item: {
196
+ listItemsByTypeStatusAndPublishedAt: (
197
+ input: { typeStatus: string },
198
+ options: ListOptions,
199
+ ) => Promise<ListPage<ArticleItemSummary>>;
200
+ get: (
201
+ input: { id: string },
202
+ options: { authMode: "userPool" },
203
+ ) => Promise<{ data?: Record<string, unknown> | null; errors?: { message?: string }[] | null }>;
204
+ };
205
+ PublishedItem: {
206
+ listPublishedItemsByTypeStatusAndPublishedAt: (
207
+ input: { typeStatus: string },
208
+ options: ListOptions,
209
+ ) => Promise<ListPage<PublishedItemSummary>>;
210
+ };
211
+ };
212
+ };
213
+
214
+ const NEWSROOM_ARTICLE_TYPE_STATUSES = ["article#draft", "article#published", "page#draft", "page#published"];
215
+ const NEWSROOM_PUBLISHED_TYPE_STATUSES = ["article#published", "page#published"];
216
+
217
+ async function collectPages<T>(
218
+ fetchPage: (options: ListOptions) => Promise<ListPage<T>>,
219
+ ): Promise<T[]> {
220
+ const collected: T[] = [];
221
+ let nextToken: string | null | undefined = null;
222
+ do {
223
+ const page: ListPage<T> = await fetchPage({ authMode: "userPool", nextToken, limit: 500 });
224
+ if (page.errors && page.errors.length > 0) {
225
+ throw new Error(page.errors.map((error) => error.message ?? "GraphQL error").join("; "));
226
+ }
227
+ for (const row of page.data ?? []) {
228
+ if (row) collected.push(row);
229
+ }
230
+ nextToken = page.nextToken;
231
+ } while (nextToken);
232
+ return collected;
233
+ }
234
+
235
+ export async function loadArticleRows(client: ArticlesDataClient): Promise<NewsroomArticleRow[]> {
236
+ const itemGroups = await Promise.all(
237
+ NEWSROOM_ARTICLE_TYPE_STATUSES.map((typeStatus) =>
238
+ collectPages((options) => client.models.Item.listItemsByTypeStatusAndPublishedAt({ typeStatus }, options)),
239
+ ),
240
+ );
241
+ const publishedGroups = await Promise.all(
242
+ NEWSROOM_PUBLISHED_TYPE_STATUSES.map((typeStatus) =>
243
+ collectPages((options) =>
244
+ client.models.PublishedItem.listPublishedItemsByTypeStatusAndPublishedAt({ typeStatus }, options),
245
+ ),
246
+ ),
247
+ );
248
+ const publishedByLineage = new Map<string, PublishedItemSummary>();
249
+ for (const published of publishedGroups.flat()) {
250
+ const lineage = published.itemLineageId ?? published.sourceItemId;
251
+ if (lineage) publishedByLineage.set(lineage, published);
252
+ }
253
+ const rows = itemGroups
254
+ .flat()
255
+ .map((item) => rowFromItem(item, publishedByLineage.get(item.lineageId ?? item.id) ?? null));
256
+ return sortRowsByUpdatedAtDescending(rows);
257
+ }
258
+
259
+ export async function loadEditableArticle(client: ArticlesDataClient, id: string): Promise<EditableArticle> {
260
+ const response = await client.models.Item.get({ id }, { authMode: "userPool" });
261
+ if (response.errors && response.errors.length > 0) {
262
+ throw new Error(response.errors.map((error) => error.message ?? "GraphQL error").join("; "));
263
+ }
264
+ const record = response.data;
265
+ if (!record) throw new Error("That article was not found.");
266
+ const metadata = parseJsonObject(record.metadata);
267
+ const status: ArticleStatus = record.status === "published" ? "published" : "draft";
268
+ return {
269
+ id: String(record.id),
270
+ type: String(record.type),
271
+ section: typeof record.section === "string" ? record.section : null,
272
+ slug: String(record.slug),
273
+ frontMatterYaml: typeof metadata.frontMatterYaml === "string" ? metadata.frontMatterYaml : "",
274
+ bodyMarkus: typeof record.bodyMarkus === "string" ? record.bodyMarkus : "",
275
+ contentHash: typeof record.contentHash === "string" ? record.contentHash : null,
276
+ status,
277
+ versionNumber: typeof record.versionNumber === "number" ? record.versionNumber : null,
278
+ aliases: Array.isArray(record.aliases) ? record.aliases.map(String) : [],
279
+ everPublished: status === "published",
280
+ };
281
+ }
282
+
283
+ export function createLiveArticlesBackend(): ArticlesBackend {
284
+ let client: ArticlesDataClient | null = null;
285
+ const dataClient = () => {
286
+ if (!client) client = generateClient<Schema>() as unknown as ArticlesDataClient;
287
+ return client;
288
+ };
289
+ return {
290
+ actions: { deriveMarkus, saveItemDraft, publishItem, unpublishItem },
291
+ listRows: () => loadArticleRows(dataClient()),
292
+ loadArticle: (id) => loadEditableArticle(dataClient(), id),
293
+ };
294
+ }
295
+
296
+ let liveBackend: ArticlesBackend | null = null;
297
+ let demoBackend: ArticlesBackend | null = null;
298
+
299
+ export function resolveArticlesBackend(demo: boolean): ArticlesBackend {
300
+ if (demo) {
301
+ demoBackend ??= createDemoArticlesBackend();
302
+ return demoBackend;
303
+ }
304
+ liveBackend ??= createLiveArticlesBackend();
305
+ return liveBackend;
306
+ }
307
+
308
+ export function stagingPreviewUrl(
309
+ stagingBaseUrl: string | undefined,
310
+ type: string,
311
+ slug: string,
312
+ staticSite: boolean,
313
+ ): string | null {
314
+ if (!stagingBaseUrl || !slug || type !== "article") return null;
315
+ const base = stagingBaseUrl.replace(/\/+$/, "");
316
+ return staticSite ? `${base}/articles/${slug}.html` : `${base}/articles/${slug}`;
317
+ }
318
+
319
+ export function uniqueSections(rows: NewsroomArticleRow[]): string[] {
320
+ const sections = new Set<string>();
321
+ for (const row of rows) {
322
+ if (row.section) sections.add(row.section);
323
+ }
324
+ return [...sections].sort();
325
+ }
@@ -19,13 +19,22 @@ export function usesNewsroomRootPaths(): boolean {
19
19
  export function toPublicNewsroomPath(internalPath: string): string {
20
20
  const publicBase = getPublicNewsroomBasePath();
21
21
  if (publicBase !== "") return internalPath;
22
- if (internalPath === "/newsroom" || internalPath === "/newsroom/") return "/";
23
- if (internalPath.startsWith("/newsroom/")) {
24
- return internalPath.slice("/newsroom".length) || "/";
22
+ const suffixStart = internalPath.search(/[?#]/);
23
+ const pathOnly = suffixStart === -1 ? internalPath : internalPath.slice(0, suffixStart);
24
+ const suffix = suffixStart === -1 ? "" : internalPath.slice(suffixStart);
25
+ if (pathOnly === "/newsroom" || pathOnly === "/newsroom/") return `/${suffix}`;
26
+ if (pathOnly.startsWith("/newsroom/")) {
27
+ return `${pathOnly.slice("/newsroom".length) || "/"}${suffix}`;
25
28
  }
26
29
  return internalPath;
27
30
  }
28
31
 
32
+ /** Maps a browser pathname to the internal `/newsroom/...` form on CMS-only hosts; identity elsewhere. */
33
+ export function browserPathToInternalNewsroomPath(browserPath: string): string {
34
+ if (!usesNewsroomRootPaths()) return browserPath;
35
+ return toInternalNewsroomPath(browserPath);
36
+ }
37
+
29
38
  export function toInternalNewsroomPath(publicPath: string): string {
30
39
  const publicBase = getPublicNewsroomBasePath();
31
40
  const normalized = publicPath.startsWith("/") ? publicPath : `/${publicPath}`;
@@ -1,3 +1,4 @@
1
+ import { toPublicNewsroomPath } from "./newsroom-base-path";
1
2
  import type {
2
3
  CategorySteeringCategory,
3
4
  SemanticRelationRecord,
@@ -93,11 +94,11 @@ export function uniqueNeighborGroupsForCategoryContext(graph: CategoryGraph, con
93
94
  export function topicHref(rootCategoryKey: string, selectedCategoryKey?: string | null): string {
94
95
  const encodedRoot = encodeURIComponent(rootCategoryKey);
95
96
  const encodedSelected = selectedCategoryKey && selectedCategoryKey !== rootCategoryKey ? `/${encodeURIComponent(selectedCategoryKey)}` : "";
96
- return `/newsroom/topics/${encodedRoot}${encodedSelected}`;
97
+ return toPublicNewsroomPath(`/newsroom/topics/${encodedRoot}${encodedSelected}`);
97
98
  }
98
99
 
99
100
  export function categoryDrilldownHref(section: "references" | "concepts", categoryKey: string): string {
100
- return `/newsroom/${section}/${encodeURIComponent(categoryKey)}`;
101
+ return toPublicNewsroomPath(`/newsroom/${section}/${encodeURIComponent(categoryKey)}`);
101
102
  }
102
103
 
103
104
  function matchesCategorySelection(category: CategorySteeringCategory, selection: string): boolean {
@@ -0,0 +1,90 @@
1
+ export type DemoArticleRecord = {
2
+ id: string;
3
+ type: string;
4
+ slug: string;
5
+ section: string | null;
6
+ title: string;
7
+ status: "draft" | "published";
8
+ frontMatterYaml: string;
9
+ bodyMarkus: string;
10
+ contentHash: string;
11
+ publishedContentHash: string | null;
12
+ versionNumber: number | null;
13
+ everPublished: boolean;
14
+ updatedAt: string;
15
+ };
16
+
17
+ export function demoContentHash(frontMatterYaml: string, bodyMarkus: string): string {
18
+ let hash = 5381;
19
+ const text = `${frontMatterYaml}\u0000${bodyMarkus}`;
20
+ for (let index = 0; index < text.length; index += 1) {
21
+ hash = ((hash << 5) + hash + text.charCodeAt(index)) >>> 0;
22
+ }
23
+ return `demo:${hash.toString(16)}`;
24
+ }
25
+
26
+ function demoRecord(
27
+ fields: Pick<DemoArticleRecord, "id" | "slug" | "section" | "title" | "bodyMarkus" | "updatedAt"> & {
28
+ type?: string;
29
+ status: "draft" | "published";
30
+ publishedBody?: string;
31
+ versionNumber: number | null;
32
+ },
33
+ ): DemoArticleRecord {
34
+ const frontMatterYaml = `title: ${fields.title}\n`;
35
+ const contentHash = demoContentHash(frontMatterYaml, fields.bodyMarkus);
36
+ const publishedContentHash = fields.status === "published"
37
+ ? demoContentHash(frontMatterYaml, fields.publishedBody ?? fields.bodyMarkus)
38
+ : null;
39
+ return {
40
+ id: fields.id,
41
+ type: fields.type ?? "article",
42
+ slug: fields.slug,
43
+ section: fields.section,
44
+ title: fields.title,
45
+ status: fields.status,
46
+ frontMatterYaml,
47
+ bodyMarkus: fields.bodyMarkus,
48
+ contentHash,
49
+ publishedContentHash,
50
+ versionNumber: fields.versionNumber,
51
+ everPublished: fields.status === "published",
52
+ updatedAt: fields.updatedAt,
53
+ };
54
+ }
55
+
56
+ export function createDemoArticleRecords(): DemoArticleRecord[] {
57
+ return [
58
+ demoRecord({
59
+ id: "demo-article-changed",
60
+ slug: "forty-billion-dollars",
61
+ section: "business",
62
+ title: "Forty Billion Dollars",
63
+ bodyMarkus: "The raise doubled again this quarter.\n\nAnalysts expect the pace to slow.\n",
64
+ publishedBody: "The raise doubled this quarter.\n",
65
+ status: "published",
66
+ versionNumber: 2,
67
+ updatedAt: "2026-10-05T16:00:00.000Z",
68
+ }),
69
+ demoRecord({
70
+ id: "demo-article-published",
71
+ slug: "harbor-cranes-return",
72
+ section: "local",
73
+ title: "Harbor Cranes Return",
74
+ bodyMarkus: "# Harbor Cranes Return\n\nThe cranes were lifted back into place on Monday.\n",
75
+ status: "published",
76
+ versionNumber: 1,
77
+ updatedAt: "2026-10-04T12:00:00.000Z",
78
+ }),
79
+ demoRecord({
80
+ id: "demo-article-draft",
81
+ slug: "council-budget-notes",
82
+ section: "politics",
83
+ title: "Council Budget Notes",
84
+ bodyMarkus: "Notes from the budget session.\n",
85
+ status: "draft",
86
+ versionNumber: null,
87
+ updatedAt: "2026-10-03T09:00:00.000Z",
88
+ }),
89
+ ];
90
+ }