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

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 +228 -50
  75. package/infra/amplify-app-shell.ts +256 -53
  76. package/infra/build-specs.js +15 -5
  77. package/infra/build-specs.ts +18 -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 +236 -11
  82. package/infra/site-config.ts +276 -15
  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,5 @@
1
+ import { toPublicNewsroomPath } from "./newsroom-base-path";
2
+
1
3
  export const FORUM_THREAD_ROUTE_PREFIX = "/newsroom/forum/";
2
4
  export const INSIGHT_THREAD_ROUTE_PREFIX = "/newsroom/insights/";
3
5
  export const FORUM_MESSAGE_HASH_PREFIX = "message-";
@@ -81,7 +83,7 @@ export function buildForumThreadUrl(
81
83
  if (options?.demo) params.set("demo", "1");
82
84
  const query = params.toString();
83
85
  const hash = options?.messageId ? getForumMessageAnchorId(options.messageId) : "";
84
- return `/newsroom/forum/${encodedThreadId}${query ? `?${query}` : ""}${hash ? `#${hash}` : ""}`;
86
+ return toPublicNewsroomPath(`/newsroom/forum/${encodedThreadId}${query ? `?${query}` : ""}${hash ? `#${hash}` : ""}`);
85
87
  }
86
88
 
87
89
  export function buildInsightThreadUrl(
@@ -93,7 +95,7 @@ export function buildInsightThreadUrl(
93
95
  if (options?.demo) params.set("demo", "1");
94
96
  const query = params.toString();
95
97
  const hash = options?.messageId ? getForumMessageAnchorId(options.messageId) : "";
96
- return `${INSIGHT_THREAD_ROUTE_PREFIX}${encodedThreadId}${query ? `?${query}` : ""}${hash ? `#${hash}` : ""}`;
98
+ return toPublicNewsroomPath(`${INSIGHT_THREAD_ROUTE_PREFIX}${encodedThreadId}${query ? `?${query}` : ""}${hash ? `#${hash}` : ""}`);
97
99
  }
98
100
 
99
101
  export function buildThreadUrlForSurface(
@@ -141,7 +143,7 @@ export function buildNewsroomForumIndexUrl(demo?: boolean): string {
141
143
  const params = new URLSearchParams();
142
144
  if (demo) params.set("demo", "1");
143
145
  const query = params.toString();
144
- return query ? `/newsroom?${query}` : "/newsroom";
146
+ return toPublicNewsroomPath(query ? `/newsroom?${query}` : "/newsroom");
145
147
  }
146
148
 
147
149
  export function buildNewsroomInsightsIndexUrl(demo?: boolean, domain?: string): string {
@@ -149,5 +151,5 @@ export function buildNewsroomInsightsIndexUrl(demo?: boolean, domain?: string):
149
151
  if (demo) params.set("demo", "1");
150
152
  if (domain?.trim()) params.set("domain", domain.trim());
151
153
  const query = params.toString();
152
- return query ? `/newsroom/insights?${query}` : "/newsroom/insights";
154
+ return toPublicNewsroomPath(query ? `/newsroom/insights?${query}` : "/newsroom/insights");
153
155
  }
@@ -1,3 +1,4 @@
1
+ import { browserPathToInternalNewsroomPath, toPublicNewsroomPath } from "./newsroom-base-path";
1
2
  export type NewsroomIndexTab = "references" | "messages" | "assignments" | "insights";
2
3
 
3
4
  export type ReferenceIndexOrder = "published" | "imported";
@@ -147,10 +148,10 @@ export function buildNewsroomIndexWebPath(
147
148
  tab: NewsroomIndexTab,
148
149
  filters: ReferencesIndexFilters | MessagesIndexFilters | AssignmentsIndexFilters | InsightsIndexFilters,
149
150
  ): string {
150
- if (tab === "references") return `/newsroom/references${buildReferencesIndexQuery(filters as ReferencesIndexFilters)}`;
151
- if (tab === "messages") return `/newsroom/messages${buildMessagesIndexQuery(filters as MessagesIndexFilters)}`;
152
- if (tab === "insights") return `/newsroom/insights${buildInsightsIndexQuery(filters as InsightsIndexFilters)}`;
153
- return `/newsroom/assignments${buildAssignmentsIndexQuery(filters as AssignmentsIndexFilters)}`;
151
+ if (tab === "references") return toPublicNewsroomPath(`/newsroom/references${buildReferencesIndexQuery(filters as ReferencesIndexFilters)}`);
152
+ if (tab === "messages") return toPublicNewsroomPath(`/newsroom/messages${buildMessagesIndexQuery(filters as MessagesIndexFilters)}`);
153
+ if (tab === "insights") return toPublicNewsroomPath(`/newsroom/insights${buildInsightsIndexQuery(filters as InsightsIndexFilters)}`);
154
+ return toPublicNewsroomPath(`/newsroom/assignments${buildAssignmentsIndexQuery(filters as AssignmentsIndexFilters)}`);
154
155
  }
155
156
 
156
157
  export function syncBrowserNewsroomIndexUrl(
@@ -168,8 +169,9 @@ export function syncBrowserNewsroomIndexUrl(
168
169
 
169
170
  /** Lineage id from `/newsroom/references/<id>` (not index-only URLs). */
170
171
  export function parseReferenceLineageIdFromNewsroomPathname(pathname: string | null | undefined): string | null {
171
- if (!pathname?.startsWith("/newsroom/references/")) return null;
172
- const segment = pathname.slice("/newsroom/references/".length).split("/")[0]?.trim() ?? "";
172
+ const internalPathname = pathname ? browserPathToInternalNewsroomPath(pathname) : null;
173
+ if (!internalPathname?.startsWith("/newsroom/references/")) return null;
174
+ const segment = internalPathname.slice("/newsroom/references/".length).split("/")[0]?.trim() ?? "";
173
175
  if (!segment) return null;
174
176
  try {
175
177
  return decodeURIComponent(segment);
@@ -180,8 +182,9 @@ export function parseReferenceLineageIdFromNewsroomPathname(pathname: string | n
180
182
 
181
183
  /** Assignment id from `/newsroom/assignments/<id>` (not index-only URLs). */
182
184
  export function parseAssignmentIdFromNewsroomPathname(pathname: string | null | undefined): string | null {
183
- if (!pathname?.startsWith("/newsroom/assignments/")) return null;
184
- const segment = pathname.slice("/newsroom/assignments/".length).split("/")[0]?.trim() ?? "";
185
+ const internalPathname = pathname ? browserPathToInternalNewsroomPath(pathname) : null;
186
+ if (!internalPathname?.startsWith("/newsroom/assignments/")) return null;
187
+ const segment = internalPathname.slice("/newsroom/assignments/".length).split("/")[0]?.trim() ?? "";
185
188
  if (!segment) return null;
186
189
  try {
187
190
  return decodeURIComponent(segment);
@@ -191,8 +194,9 @@ export function parseAssignmentIdFromNewsroomPathname(pathname: string | null |
191
194
  }
192
195
 
193
196
  export function parseInsightThreadIdFromNewsroomPathname(pathname: string | null | undefined): string | null {
194
- if (!pathname?.startsWith("/newsroom/insights/")) return null;
195
- const segment = pathname.slice("/newsroom/insights/".length).split("/")[0]?.trim() ?? "";
197
+ const internalPathname = pathname ? browserPathToInternalNewsroomPath(pathname) : null;
198
+ if (!internalPathname?.startsWith("/newsroom/insights/")) return null;
199
+ const segment = internalPathname.slice("/newsroom/insights/".length).split("/")[0]?.trim() ?? "";
196
200
  if (!segment) return null;
197
201
  try {
198
202
  return decodeURIComponent(segment);
@@ -0,0 +1,142 @@
1
+ import { generateClient } from "aws-amplify/data";
2
+ import { getUrl, uploadData } from "aws-amplify/storage";
3
+ import type { Schema } from "../amplify/data/resource";
4
+ import type { MediaAssetFields, MediaAssetRecord, NewsroomMediaBackend } from "./newsroom-media";
5
+
6
+ type MediaListPage = {
7
+ data?: (Record<string, unknown> | null)[] | null;
8
+ nextToken?: string | null;
9
+ errors?: { message?: string }[] | null;
10
+ };
11
+
12
+ export type MediaDataClient = {
13
+ models: {
14
+ MediaAsset: {
15
+ create: (
16
+ input: MediaAssetFields,
17
+ options: { authMode: "userPool" },
18
+ ) => Promise<{ errors?: { message?: string }[] | null }>;
19
+ listMediaAssetsByItemAndSortKey: (
20
+ input: { itemId: string },
21
+ options: { authMode: "userPool"; nextToken?: string | null; limit?: number },
22
+ ) => Promise<MediaListPage>;
23
+ };
24
+ };
25
+ };
26
+
27
+ export type MediaStorageApi = {
28
+ upload: (input: { path: string; data: Blob; contentType: string }) => Promise<void>;
29
+ signedUrl: (path: string) => Promise<string>;
30
+ };
31
+
32
+ function failOnErrors(errors: { message?: string }[] | null | undefined): void {
33
+ if (errors && errors.length > 0) {
34
+ throw new Error(errors.map((error) => error.message ?? "GraphQL error").join("; "));
35
+ }
36
+ }
37
+
38
+ function srcPathFromMetadata(metadata: unknown): string | null {
39
+ let parsed = metadata;
40
+ if (typeof metadata === "string") {
41
+ try {
42
+ parsed = JSON.parse(metadata);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ if (typeof parsed !== "object" || parsed === null) return null;
48
+ const srcPath = (parsed as Record<string, unknown>).srcPath;
49
+ return typeof srcPath === "string" ? srcPath : null;
50
+ }
51
+
52
+ export function createMediaBackend(client: () => MediaDataClient, storage: MediaStorageApi): NewsroomMediaBackend {
53
+ return {
54
+ uploadImage: ({ storagePath, data, contentType }) =>
55
+ storage.upload({ path: storagePath, data, contentType }),
56
+ async createMediaAsset(fields) {
57
+ const response = await client().models.MediaAsset.create(fields, { authMode: "userPool" });
58
+ failOnErrors(response.errors);
59
+ },
60
+ async listMediaAssets(itemId) {
61
+ const records: MediaAssetRecord[] = [];
62
+ let nextToken: string | null | undefined = null;
63
+ do {
64
+ const page: MediaListPage = await client().models.MediaAsset.listMediaAssetsByItemAndSortKey(
65
+ { itemId },
66
+ { authMode: "userPool", nextToken, limit: 500 },
67
+ );
68
+ failOnErrors(page.errors);
69
+ for (const row of page.data ?? []) {
70
+ if (!row) continue;
71
+ records.push({
72
+ id: String(row.id),
73
+ sortKey: String(row.sortKey),
74
+ storagePath: typeof row.storagePath === "string" ? row.storagePath : null,
75
+ alt: typeof row.alt === "string" ? row.alt : "",
76
+ srcPath: srcPathFromMetadata(row.metadata),
77
+ });
78
+ }
79
+ nextToken = page.nextToken;
80
+ } while (nextToken);
81
+ return records;
82
+ },
83
+ imageUrl: (storagePath) => storage.signedUrl(storagePath),
84
+ };
85
+ }
86
+
87
+ const amplifyStorage: MediaStorageApi = {
88
+ async upload({ path, data, contentType }) {
89
+ await uploadData({ path, data, options: { contentType } }).result;
90
+ },
91
+ async signedUrl(path) {
92
+ const { url } = await getUrl({ path });
93
+ return url.toString();
94
+ },
95
+ };
96
+
97
+ export function createLiveMediaBackend(): NewsroomMediaBackend {
98
+ let client: MediaDataClient | null = null;
99
+ return createMediaBackend(() => {
100
+ if (!client) client = generateClient<Schema>() as unknown as MediaDataClient;
101
+ return client;
102
+ }, amplifyStorage);
103
+ }
104
+
105
+ export function createDemoMediaBackend(): NewsroomMediaBackend {
106
+ const objects = new Map<string, Blob>();
107
+ const rows = new Map<string, MediaAssetFields[]>();
108
+ return {
109
+ async uploadImage({ storagePath, data }) {
110
+ objects.set(storagePath, data);
111
+ },
112
+ async createMediaAsset(fields) {
113
+ rows.set(fields.itemId, [...(rows.get(fields.itemId) ?? []), fields]);
114
+ },
115
+ async listMediaAssets(itemId) {
116
+ return (rows.get(itemId) ?? []).map((fields) => ({
117
+ id: fields.id,
118
+ sortKey: fields.sortKey,
119
+ storagePath: fields.storagePath,
120
+ alt: fields.alt,
121
+ srcPath: srcPathFromMetadata(fields.metadata),
122
+ }));
123
+ },
124
+ async imageUrl(storagePath) {
125
+ const object = objects.get(storagePath);
126
+ if (!object) throw new Error(`No demo object at ${storagePath}.`);
127
+ return URL.createObjectURL(object);
128
+ },
129
+ };
130
+ }
131
+
132
+ let liveBackend: NewsroomMediaBackend | null = null;
133
+ let demoBackend: NewsroomMediaBackend | null = null;
134
+
135
+ export function resolveMediaBackend(demo: boolean): NewsroomMediaBackend {
136
+ if (demo) {
137
+ demoBackend ??= createDemoMediaBackend();
138
+ return demoBackend;
139
+ }
140
+ liveBackend ??= createLiveMediaBackend();
141
+ return liveBackend;
142
+ }
@@ -0,0 +1,182 @@
1
+ export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
2
+
3
+ export const IMAGE_CONTENT_TYPES: Record<string, string> = {
4
+ png: "image/png",
5
+ jpg: "image/jpeg",
6
+ jpeg: "image/jpeg",
7
+ gif: "image/gif",
8
+ webp: "image/webp",
9
+ avif: "image/avif",
10
+ svg: "image/svg+xml",
11
+ };
12
+
13
+ export type ImageFileLike = { name: string; size: number };
14
+
15
+ function extensionOf(filename: string): string {
16
+ const dot = filename.lastIndexOf(".");
17
+ return dot === -1 ? "" : filename.slice(dot + 1).toLowerCase();
18
+ }
19
+
20
+ export function contentTypeForFilename(filename: string): string | null {
21
+ return IMAGE_CONTENT_TYPES[extensionOf(filename)] ?? null;
22
+ }
23
+
24
+ export function isSvgFilename(filename: string): boolean {
25
+ return extensionOf(filename) === "svg";
26
+ }
27
+
28
+ function sanitizeSegment(value: string): string {
29
+ return value
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9._-]+/g, "-")
32
+ .replace(/-{2,}/g, "-")
33
+ .replace(/^[-.]+|-+$/g, "");
34
+ }
35
+
36
+ export function srcPathFor(slug: string, filename: string, existingSrcPaths: Iterable<string> = []): string {
37
+ const taken = new Set(existingSrcPaths);
38
+ const extension = sanitizeSegment(extensionOf(filename));
39
+ const dot = filename.lastIndexOf(".");
40
+ const rawStem = dot === -1 ? filename : filename.slice(0, dot);
41
+ const stem = sanitizeSegment(rawStem) || "image";
42
+ const directory = sanitizeSegment(slug) || "article";
43
+ const suffix = extension ? `.${extension}` : "";
44
+ let candidate = `assets/${directory}/${stem}${suffix}`;
45
+ for (let counter = 2; taken.has(candidate); counter += 1) {
46
+ candidate = `assets/${directory}/${stem}-${counter}${suffix}`;
47
+ }
48
+ return candidate;
49
+ }
50
+
51
+ export async function sha256Hex(data: string | ArrayBuffer): Promise<string> {
52
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
53
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
54
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
55
+ }
56
+
57
+ export async function mediaIdFor(itemId: string, srcPath: string): Promise<string> {
58
+ return `media-${itemId}-${(await sha256Hex(srcPath)).slice(0, 10)}`;
59
+ }
60
+
61
+ export function validateImageFile(file: ImageFileLike): string | null {
62
+ if (contentTypeForFilename(file.name) === null) {
63
+ return "Choose a PNG, JPEG, GIF, WebP, AVIF or SVG image.";
64
+ }
65
+ if (file.size <= 0) return "That file is empty.";
66
+ if (file.size > MAX_IMAGE_BYTES) return "Images can be at most 10 MB.";
67
+ return null;
68
+ }
69
+
70
+ function altAttributeValue(alt: string): string {
71
+ return alt.replace(/[\r\n]+/g, " ").replace(/"/g, "'").replace(/[{}]/g, "");
72
+ }
73
+
74
+ export function imageDirective(srcPath: string, alt: string): string {
75
+ return `::image{src="${srcPath}" alt="${altAttributeValue(alt)}" layout="inline"}`;
76
+ }
77
+
78
+ export type MediaAssetFields = {
79
+ id: string;
80
+ itemId: string;
81
+ type: "image";
82
+ role: "body";
83
+ sortKey: string;
84
+ storagePath: string;
85
+ alt: string;
86
+ width?: number;
87
+ height?: number;
88
+ metadata: string;
89
+ };
90
+
91
+ export function mediaSortKey(position: number, srcPath: string): string {
92
+ return `${String(position).padStart(3, "0")}#${srcPath}`;
93
+ }
94
+
95
+ export type SelectionInsertion = { body: string; altSelectionStart: number };
96
+
97
+ function countEdgeNewlines(text: string, fromEnd: boolean): number {
98
+ let count = 0;
99
+ while (count < 2 && (fromEnd ? text[text.length - 1 - count] : text[count]) === "\n") count += 1;
100
+ return count;
101
+ }
102
+
103
+ export function insertDirectiveAtSelection(
104
+ body: string,
105
+ selectionStart: number,
106
+ selectionEnd: number,
107
+ directive: string,
108
+ ): SelectionInsertion {
109
+ const before = body.slice(0, selectionStart);
110
+ const after = body.slice(selectionEnd);
111
+ const prefix = before === "" ? "" : "\n".repeat(2 - countEdgeNewlines(before, true));
112
+ const suffix = after === "" ? "\n" : "\n".repeat(2 - countEdgeNewlines(after, false));
113
+ const directiveStart = before.length + prefix.length;
114
+ return {
115
+ body: `${before}${prefix}${directive}${suffix}${after}`,
116
+ altSelectionStart: directiveStart + directive.indexOf('alt="') + 'alt="'.length,
117
+ };
118
+ }
119
+
120
+ export type MediaAssetRecord = {
121
+ id: string;
122
+ sortKey: string;
123
+ storagePath: string | null;
124
+ alt: string;
125
+ srcPath: string | null;
126
+ };
127
+
128
+ export type NewsroomMediaBackend = {
129
+ uploadImage: (input: { storagePath: string; data: Blob; contentType: string }) => Promise<void>;
130
+ createMediaAsset: (fields: MediaAssetFields) => Promise<void>;
131
+ listMediaAssets: (itemId: string) => Promise<MediaAssetRecord[]>;
132
+ imageUrl: (storagePath: string) => Promise<string>;
133
+ };
134
+
135
+ export type ImageDimensionReader = (file: Blob, filename: string) => Promise<{ width: number; height: number } | null>;
136
+
137
+ export type UploadedImage = { srcPath: string; directive: string; imageUrl: string };
138
+
139
+ export async function uploadArticleImage(
140
+ backend: NewsroomMediaBackend,
141
+ readDimensions: ImageDimensionReader,
142
+ input: { itemId: string; slug: string; file: File },
143
+ ): Promise<UploadedImage> {
144
+ const problem = validateImageFile(input.file);
145
+ if (problem) throw new Error(problem);
146
+ const contentType = contentTypeForFilename(input.file.name) as string;
147
+ const existing = await backend.listMediaAssets(input.itemId);
148
+ const srcPath = srcPathFor(
149
+ input.slug,
150
+ input.file.name,
151
+ existing.map((asset) => asset.srcPath).filter((path): path is string => path !== null),
152
+ );
153
+ const bytes = await input.file.arrayBuffer();
154
+ const sha256 = await sha256Hex(bytes);
155
+ const dimensions = await readDimensions(input.file, input.file.name);
156
+ const storagePath = `media/${srcPath}`;
157
+ await backend.uploadImage({ storagePath, data: input.file, contentType });
158
+ await backend.createMediaAsset({
159
+ id: await mediaIdFor(input.itemId, srcPath),
160
+ itemId: input.itemId,
161
+ type: "image",
162
+ role: "body",
163
+ sortKey: mediaSortKey(existing.length + 1, srcPath),
164
+ storagePath,
165
+ alt: input.file.name,
166
+ ...(dimensions ?? {}),
167
+ metadata: JSON.stringify({ srcPath, sha256, bytes: bytes.byteLength, contentType }),
168
+ });
169
+ return { srcPath, directive: imageDirective(srcPath, ""), imageUrl: await backend.imageUrl(storagePath) };
170
+ }
171
+
172
+ export async function readImageDimensions(file: Blob, filename: string): Promise<{ width: number; height: number } | null> {
173
+ if (isSvgFilename(filename)) return null;
174
+ try {
175
+ const bitmap = await createImageBitmap(file);
176
+ const size = { width: bitmap.width, height: bitmap.height };
177
+ bitmap.close();
178
+ return size;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
@@ -2,6 +2,7 @@ import { newsroomHref, toPublicNewsroomPath } from "./newsroom-base-path";
2
2
 
3
3
  export type NewsroomNavTabId =
4
4
  | "overview"
5
+ | "articles"
5
6
  | "messages"
6
7
  | "insights"
7
8
  | "assignments"
@@ -22,12 +23,14 @@ export type NewsroomNavItem = {
22
23
 
23
24
  export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
24
25
  { id: "overview", label: "Overview", detail: "Desk home", href: newsroomHref(), mobilePrimary: true },
26
+ { id: "articles", label: "Articles", detail: "Drafts & published", href: newsroomHref("articles"), mobilePrimary: true },
25
27
  { id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments"), mobilePrimary: true },
26
28
  { id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references"), mobilePrimary: true },
27
29
  { id: "messages", label: "Messages", detail: "Forum", href: newsroomHref("messages") },
28
30
  { id: "insights", label: "Insights", detail: "Research threads", href: newsroomHref("insights") },
29
31
  { id: "topics", label: "Topics", detail: "Taxonomy", href: newsroomHref("topics"), mobilePrimary: true },
30
32
  { id: "concepts", label: "Concepts", detail: "Ontology", href: newsroomHref("concepts") },
33
+ { id: "search", label: "Search", detail: "Knowledge base", href: newsroomHref("search") },
31
34
  { id: "administration", label: "Administration", detail: "Users & policies", href: newsroomHref("administration") },
32
35
  ];
33
36
 
@@ -0,0 +1,40 @@
1
+ function originOf(value: string): string {
2
+ const trimmed = value.replace(/\/+$/, "");
3
+ try {
4
+ const parsed = new URL(trimmed);
5
+ const hostname = parsed.hostname === "127.0.0.1" || parsed.hostname === "::1" ? "localhost" : parsed.hostname;
6
+ return `${parsed.protocol}//${hostname}${parsed.port ? `:${parsed.port}` : ""}`;
7
+ } catch {
8
+ return trimmed;
9
+ }
10
+ }
11
+
12
+ function pathOf(value: string): string | null {
13
+ try {
14
+ return new URL(value).pathname.replace(/\/+$/, "");
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Puts the redirect URLs of the current origin first; among those, the
22
+ * newsroom return path leads when the site has one, so Amplify (which signs in
23
+ * with the first entry) returns an editor to the newsroom.
24
+ */
25
+ export function prioritizeRedirectUrlsForOrigin(
26
+ urls: readonly string[],
27
+ currentOrigin: string,
28
+ publicNewsroomBasePath: string,
29
+ ): string[] {
30
+ const origin = originOf(currentOrigin);
31
+ const newsroomPath = publicNewsroomBasePath.replace(/\/+$/, "");
32
+ const head = urls.filter((value) => originOf(value) === origin);
33
+ if (head.length === 0) return [...urls];
34
+ const tail = urls.filter((value) => originOf(value) !== origin);
35
+ const newsroomFirst =
36
+ newsroomPath === ""
37
+ ? head
38
+ : [...head.filter((value) => pathOf(value) === newsroomPath), ...head.filter((value) => pathOf(value) !== newsroomPath)];
39
+ return [...newsroomFirst, ...tail];
40
+ }
@@ -1,3 +1,4 @@
1
+ import { toInternalReaderPath, toPublicReaderPath } from "./reader-base-path";
1
2
  import { PAPYRUS_OBJECT_KINDS, type PapyrusObjectKind } from "./papyrus-object-kinds";
2
3
  import {
3
4
  buildNewsroomIndexWebPath,
@@ -52,7 +53,7 @@ export function buildWebUiContext(webPath: string): PapyrusWebUiContext {
52
53
  export function webPathToPapyrusLocation(webPath: string): PapyrusWebUiContext {
53
54
  const normalized = normalizeWebPath(webPath);
54
55
  const url = new URL(normalized, "https://papyrus.local");
55
- const pathname = url.pathname || "/";
56
+ const pathname = toInternalReaderPath(url.pathname || "/");
56
57
 
57
58
  if (pathname === "/" || pathname === "") {
58
59
  return location("papyrus://site/home", normalized, { label: "Papyrus home" });
@@ -81,7 +82,7 @@ export function webPathToPapyrusLocation(webPath: string): PapyrusWebUiContext {
81
82
  }
82
83
 
83
84
  if (!pathname.startsWith("/newsroom")) {
84
- const tail = pathname.replace(/^\/+/, "");
85
+ const tail = (url.pathname || "/").replace(/^\/+/, "");
85
86
  return location(`papyrus://site/path/${encodeURIComponent(tail)}`, normalized, { label: pathname });
86
87
  }
87
88
 
@@ -215,9 +216,9 @@ export function papyrusUriToWebPath(uri: string): PapyrusUriToWebPathResult {
215
216
  const { kind, id } = parsed;
216
217
 
217
218
  if (kind === "site") {
218
- if (id === "home") return { ok: true, papyrusLocationUri: raw, webPath: "/" };
219
- if (id === "archive") return { ok: true, papyrusLocationUri: raw, webPath: "/archive" };
220
- if (id === "settings") return { ok: true, papyrusLocationUri: raw, webPath: "/settings" };
219
+ if (id === "home") return { ok: true, papyrusLocationUri: raw, webPath: toPublicReaderPath("/") };
220
+ if (id === "archive") return { ok: true, papyrusLocationUri: raw, webPath: toPublicReaderPath("/archive") };
221
+ if (id === "settings") return { ok: true, papyrusLocationUri: raw, webPath: toPublicReaderPath("/settings") };
221
222
  if (id.startsWith("path/")) {
222
223
  return { ok: true, papyrusLocationUri: raw, webPath: `/${decodeURIComponent(id.slice("path/".length))}` };
223
224
  }
@@ -449,7 +450,7 @@ function objectKindToWebPath(kind: PapyrusObjectKind, objectId: string): string
449
450
  if (kind === "category") return `/newsroom/topics?category=${encoded}`;
450
451
  if (kind === "semanticNode") return `/newsroom/concepts?node=${encoded}`;
451
452
  if (kind === "newsroomSection") return `/newsroom/sections/${encoded}`;
452
- if (kind === "item") return `/articles/${encoded}`;
453
+ if (kind === "item") return toPublicReaderPath(`/articles/${encoded}`);
453
454
  return `/newsroom?object=${encodeURIComponent(kind)}:${encoded}`;
454
455
  }
455
456
 
@@ -1,6 +1,7 @@
1
1
  import { getEditionSectionItems } from "./edition-sections";
2
2
  import type { EditionContent } from "./content-types";
3
3
  import type { PublicationItem } from "./publication-items";
4
+ import { toPublicReaderPath } from "./reader-base-path";
4
5
 
5
6
  export type PresentationFooterEntry = {
6
7
  section: string;
@@ -43,9 +44,9 @@ export type PresentationFooterGeometry = {
43
44
  };
44
45
 
45
46
  export const PRESENTATION_FOOTER_UTILITIES: PresentationFooterUtilityEntry[] = [
46
- { id: "archive", label: "Archive", href: "/archive", disabled: false },
47
+ { id: "archive", label: "Archive", href: toPublicReaderPath("/archive"), disabled: false },
47
48
  { id: "newsDesk", label: "Newsroom", href: "/newsroom", disabled: false },
48
- { id: "settings", label: "Settings", href: "/settings", disabled: false },
49
+ { id: "settings", label: "Settings", href: toPublicReaderPath("/settings"), disabled: false },
49
50
  { id: "login", label: "LOGIN", disabled: true },
50
51
  ];
51
52
 
@@ -1,4 +1,5 @@
1
1
  import {
2
+ clearCache,
2
3
  layoutNextLine,
3
4
  prepareWithSegments,
4
5
  type LayoutCursor,
@@ -78,14 +79,18 @@ export function layoutAllTextLines({
78
79
  linePaintHeight,
79
80
  fontSize,
80
81
  fontFamily,
81
- }: Omit<LayoutTextLinesOptions, "cursor" | "maxHeight" | "obstacles">): TextLine[] {
82
+ obstacles = [],
83
+ }: Omit<LayoutTextLinesOptions, "cursor" | "maxHeight" | "obstacles"> & { obstacles?: TextObstacle[] }): TextLine[] {
82
84
  const lines: TextLine[] = [];
83
85
  let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 };
84
86
 
85
87
  for (let lineIndex = 0; lineIndex < 2000; lineIndex += 1) {
86
- const line = layoutNextLine(prepared, cursor, maxWidth);
88
+ const y = lineIndex * lineHeight;
89
+ const slot = getAvailableSlot(maxWidth, y, linePaintHeight, obstacles);
90
+ if (!slot) continue;
91
+ const line = layoutNextLine(prepared, cursor, slot.width);
87
92
  if (!line) return lines;
88
- lines.push(toTextLine(line, 0, lineIndex * lineHeight, fontSize, fontFamily, lineHeight, linePaintHeight));
93
+ lines.push(toTextLine(line, slot.x, y, fontSize, fontFamily, lineHeight, linePaintHeight));
89
94
  cursor = line.end;
90
95
  }
91
96
 
@@ -177,3 +182,7 @@ function toTextLine(line: LayoutLine, x: number, y: number, fontSize: number, fo
177
182
  function clamp(value: number, min: number, max: number): number {
178
183
  return Math.max(min, Math.min(max, value));
179
184
  }
185
+
186
+ export function clearPretextMeasurementCache(): void {
187
+ clearCache();
188
+ }
@@ -0,0 +1,24 @@
1
+ import { SITE_BRAND } from "./site-brand";
2
+
3
+ export function normalizeReaderBasePath(value: string | undefined | null): string {
4
+ const trimmed = (value ?? "").trim().replace(/\/+$/, "");
5
+ if (!trimmed) return "";
6
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
7
+ }
8
+
9
+ export function getReaderBasePath(): string {
10
+ return normalizeReaderBasePath(SITE_BRAND.readerBasePath);
11
+ }
12
+
13
+ export function toPublicReaderPath(path: string, readerBasePath: string = getReaderBasePath()): string {
14
+ if (!readerBasePath) return path;
15
+ if (path === "/" || path === "") return readerBasePath;
16
+ return `${readerBasePath}${path.startsWith("/") ? path : `/${path}`}`;
17
+ }
18
+
19
+ export function toInternalReaderPath(path: string, readerBasePath: string = getReaderBasePath()): string {
20
+ if (!readerBasePath) return path;
21
+ if (path === readerBasePath || path === `${readerBasePath}/`) return "/";
22
+ if (path.startsWith(`${readerBasePath}/`)) return path.slice(readerBasePath.length);
23
+ return path;
24
+ }
@@ -1,4 +1,15 @@
1
1
  import { graphqlContentRepository } from "./graphql-content-repository";
2
+ import { SITE_BRAND } from "./site-brand";
3
+ import { getContentSource } from "./site-env";
4
+ import type { RendererConfig } from "./renderer-config";
5
+
6
+ export function siteEnumeratesReaderRoutesAtBuild(): boolean {
7
+ return siteServesNextReaderRoutes() && getContentSource() !== "drafts";
8
+ }
9
+
10
+ export function siteServesNextReaderRoutes(rendererKind: RendererConfig["kind"] = SITE_BRAND.renderer.kind): boolean {
11
+ return rendererKind !== "markus";
12
+ }
2
13
 
3
14
  const MONTH_NAMES = [
4
15
  "january",
@@ -23,6 +34,7 @@ type EditionDateStaticParam = {
23
34
 
24
35
  export async function generateEditionDateStaticParams(): Promise<EditionDateStaticParam[]> {
25
36
  if (process.env.NODE_ENV === "development") return [];
37
+ if (!siteEnumeratesReaderRoutesAtBuild()) return [];
26
38
 
27
39
  const { editions } = await graphqlContentRepository.listPublishedEditions({ limit: 100 });
28
40
  const params: EditionDateStaticParam[] = [];
@@ -35,6 +47,7 @@ export async function generateEditionDateStaticParams(): Promise<EditionDateStat
35
47
 
36
48
  export async function generateArticleStaticParams(): Promise<Array<{ slug: string }>> {
37
49
  if (process.env.NODE_ENV === "development") return [];
50
+ if (!siteEnumeratesReaderRoutesAtBuild()) return [];
38
51
 
39
52
  const slugs = await graphqlContentRepository.listArticleSlugs();
40
53
  return slugs.map((slug) => ({ slug }));
@@ -17,6 +17,11 @@ export type ReferenceRejectionReasonCode = typeof REFERENCE_REJECTION_REASON_COD
17
17
  const REFERENCE_REJECTION_REASON_CODE_SET = new Set<string>(REFERENCE_REJECTION_REASON_CODES);
18
18
  const SCOPE_TRAINING_NEGATIVE_REASON_CODE_SET = new Set<string>(["out_of_scope", "policy_exclusion"]);
19
19
 
20
+ /** Rejection reasons that say the source is not relevant (scope-training negatives). */
21
+ export function isScopeTrainingNegativeReason(code: string): boolean {
22
+ return SCOPE_TRAINING_NEGATIVE_REASON_CODE_SET.has(code);
23
+ }
24
+
20
25
  export function normalizeReferenceCurationStatus(value: unknown, defaultStatus: ReferenceCurationStatus = "pending"): ReferenceCurationStatus {
21
26
  const normalized = normalizePolicyToken(value ?? defaultStatus);
22
27
  if (normalized === "accepted" || normalized === "accept" || normalized === "ready" || normalized === "trusted") return "accepted";