@anthusai/papyrus 1.0.0-next.9 → 1.1.0

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 (222) hide show
  1. package/amplify/auth/prospectus-google-client.js +66 -0
  2. package/amplify/auth/prospectus-google-client.ts +71 -0
  3. package/amplify/auth/redirect-urls.js +54 -0
  4. package/amplify/auth/redirect-urls.ts +66 -0
  5. package/amplify/auth/resource.js +23 -5
  6. package/amplify/auth/resource.ts +31 -5
  7. package/amplify/data/resource.js +42 -80
  8. package/amplify/data/resource.ts +42 -80
  9. package/amplify/data/schema.js +45 -62
  10. package/amplify/data/schema.ts +47 -62
  11. package/amplify/functions/category-action/handler.ts +5 -3
  12. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  13. package/amplify/functions/console-chat-responder/resource.js +0 -7
  14. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  15. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  16. package/amplify/functions/content-actions/handler.py +143 -1
  17. package/amplify/functions/content-actions/requirements.txt +2 -1
  18. package/amplify/functions/content-actions/resource.js +6 -1
  19. package/amplify/functions/content-actions/resource.ts +9 -2
  20. package/amplify/functions/email-submission-processor/handler.py +3 -1
  21. package/amplify/functions/renditions/handler.py +120 -0
  22. package/amplify/functions/renditions/requirements.txt +4 -0
  23. package/amplify/functions/ses-inbound-receive/handler.ts +32 -4
  24. package/amplify/functions/shared/email-submission.ts +109 -1
  25. package/amplify/functions/shared/python-bundle.js +42 -2
  26. package/amplify/functions/shared/python-bundle.ts +47 -2
  27. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  28. package/amplify/inbound-email/ses-intake.js +86 -0
  29. package/amplify/inbound-email/ses-intake.ts +110 -0
  30. package/amplify/plugins/registry.js +19 -0
  31. package/amplify/plugins/registry.ts +23 -0
  32. package/amplify/plugins/signup/function/handler.py +200 -0
  33. package/amplify/plugins/signup/function/resource.js +27 -0
  34. package/amplify/plugins/signup/function/resource.ts +35 -0
  35. package/amplify/plugins/signup/papyrus.plugin.js +59 -0
  36. package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
  37. package/amplify/plugins/signup/schema.js +40 -0
  38. package/amplify/plugins/signup/schema.ts +41 -0
  39. package/amplify/plugins/signup/waf.js +48 -0
  40. package/amplify/plugins/signup/waf.ts +51 -0
  41. package/amplify/plugins/types.js +1 -0
  42. package/amplify/plugins/types.ts +39 -0
  43. package/amplify/plugins/wire.js +24 -0
  44. package/amplify/plugins/wire.ts +26 -0
  45. package/amplify/publication/default-publication.js +18 -0
  46. package/amplify/publication/default-publication.ts +20 -0
  47. package/amplify/publication/invalidation-alarm.js +26 -0
  48. package/amplify/publication/invalidation-alarm.ts +30 -0
  49. package/amplify/publication/redirect-and-index.js +37 -0
  50. package/amplify/publication/renditions-function.js +74 -0
  51. package/amplify/publication/renditions-function.ts +112 -0
  52. package/amplify/publication/renditions-settings.js +51 -0
  53. package/amplify/publication/renditions-settings.ts +58 -0
  54. package/amplify/publication/reproject-on-deploy.js +73 -0
  55. package/amplify/publication/reproject-on-deploy.ts +78 -0
  56. package/amplify/publication/s3-projection-settings.js +67 -0
  57. package/amplify/publication/s3-projection-settings.ts +101 -0
  58. package/amplify/publication/s3-projection.js +151 -0
  59. package/amplify/publication/s3-projection.ts +201 -0
  60. package/amplify/resolver-waves.js +26 -0
  61. package/amplify/resolver-waves.ts +34 -0
  62. package/amplify/revalidate-invalidation-settings.js +30 -0
  63. package/amplify/revalidate-invalidation-settings.ts +35 -0
  64. package/amplify/revalidate-secret-policy.js +13 -0
  65. package/amplify/revalidate-secret-policy.ts +19 -0
  66. package/amplify/site-backend-config.js +65 -1
  67. package/amplify/site-backend-config.ts +153 -2
  68. package/amplify/site-backend-names.js +103 -0
  69. package/amplify/site-backend-names.ts +166 -0
  70. package/amplify/site-backend.js +150 -176
  71. package/amplify/site-backend.ts +189 -201
  72. package/amplify/storage-backups/construct.js +31 -0
  73. package/amplify/storage-backups/construct.ts +47 -0
  74. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
  75. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  76. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
  77. package/app/[year]/[month]/[day]/page.tsx +1 -1
  78. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
  79. package/app/api/archive/editions/route.ts +1 -1
  80. package/app/api/auth/prospectus/callback/route.ts +9 -0
  81. package/app/api/auth/prospectus/selection/route.ts +9 -0
  82. package/app/api/auth/prospectus/session/route.ts +9 -0
  83. package/app/api/auth/prospectus/sign-in/route.ts +9 -0
  84. package/app/api/auth/prospectus/sign-out/route.ts +9 -0
  85. package/app/api/cyclotron/priming-proof/route.ts +11 -0
  86. package/app/api/media/[...path]/route.ts +5 -16
  87. package/app/api/revalidate/route.ts +15 -7
  88. package/app/archive/page.tsx +1 -1
  89. package/app/articles/[slug]/page.tsx +7 -5
  90. package/app/blog-rhythm.css +278 -0
  91. package/app/cyclotron/page.tsx +13 -0
  92. package/app/globals.css +2 -0
  93. package/app/layout.tsx +16 -0
  94. package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
  95. package/app/newsroom/[section]/page.tsx +6 -3
  96. package/app/newsroom/layout.tsx +4 -9
  97. package/app/newsroom/settings/page.tsx +6 -0
  98. package/app/newsroom/work/page.tsx +10 -0
  99. package/app/page.tsx +22 -8
  100. package/app/robots.ts +2 -5
  101. package/app/tailwind.css +1 -6
  102. package/components/amplify-client-provider.tsx +5 -22
  103. package/components/archive-shell.tsx +2 -22
  104. package/components/article-video.tsx +152 -5
  105. package/components/blog-page-background.tsx +23 -6
  106. package/components/news-desk-page.tsx +4 -15
  107. package/components/news-desk-taxonomy-client.ts +90 -3
  108. package/components/newspaper.tsx +6 -2
  109. package/components/newsroom-access-gate.tsx +2 -1
  110. package/components/newsroom-board-view.tsx +99 -0
  111. package/components/newsroom-client-shell.tsx +12 -0
  112. package/components/newsroom-curation-provider.tsx +17 -0
  113. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  114. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  115. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  116. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  117. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  118. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  119. package/components/newsroom-newsprint-board.css +48 -0
  120. package/components/newsroom-ops-overview.tsx +28 -11
  121. package/components/newsroom-ops-shell.tsx +7 -1
  122. package/components/newsroom-organizer-shell.tsx +130 -0
  123. package/components/newsroom-organizer.css +63 -0
  124. package/components/newsroom-references-view.tsx +92 -8
  125. package/components/papyrus-console-shell.tsx +4 -2
  126. package/components/pictogram-figure.tsx +9 -2
  127. package/components/presentation-header.tsx +135 -0
  128. package/components/presentation-rhythm-hrule.tsx +17 -0
  129. package/components/priming-proof-page.tsx +94 -0
  130. package/components/publication-cyclotron-panel.tsx +57 -0
  131. package/components/reader-auth-control.tsx +8 -5
  132. package/components/reference-curation-controls.tsx +77 -0
  133. package/components/reference-cyclotron-status.tsx +109 -0
  134. package/components/reference-priming.tsx +18 -0
  135. package/components/reference-relevance-review.tsx +75 -0
  136. package/components/settings-page.tsx +2 -1
  137. package/components/signup-entries-panel.tsx +94 -0
  138. package/components/topic-steering-workspace.tsx +78 -8
  139. package/components/use-blog-header-obstacles.ts +81 -0
  140. package/components/use-brand-fonts-ready.ts +28 -0
  141. package/components/use-near-viewport.ts +30 -0
  142. package/components/use-rhythm-overlay.ts +29 -0
  143. package/infra/amplify-app-shell.js +166 -27
  144. package/infra/amplify-app-shell.ts +180 -31
  145. package/infra/build-specs.js +23 -7
  146. package/infra/build-specs.ts +27 -10
  147. package/infra/site-config.js +248 -36
  148. package/infra/site-config.ts +313 -51
  149. package/lib/amplify-outputs-path.ts +5 -1
  150. package/lib/amplify-server-runtime.ts +36 -1
  151. package/lib/approved-priming-proof-reports.ts +6 -0
  152. package/lib/blog-feature-solver.ts +459 -0
  153. package/lib/blog-rhythm.ts +134 -0
  154. package/lib/brand-fonts-watch.ts +43 -0
  155. package/lib/brand-fonts.ts +112 -0
  156. package/lib/cached-content-repository.ts +12 -0
  157. package/lib/category-repository.ts +43 -93
  158. package/lib/content-item-lifecycle.ts +55 -0
  159. package/lib/content-repository.ts +3 -0
  160. package/lib/content-types.ts +4 -0
  161. package/lib/curation-client.ts +61 -0
  162. package/lib/edition-routes.ts +31 -29
  163. package/lib/graphql-content-repository.ts +56 -22
  164. package/lib/media-route-response.ts +45 -0
  165. package/lib/newspaper-layout.ts +3 -2
  166. package/lib/newsroom-base-path.ts +12 -3
  167. package/lib/newsroom-board.ts +99 -0
  168. package/lib/newsroom-category-drilldown.ts +3 -2
  169. package/lib/newsroom-forum-routes.ts +6 -4
  170. package/lib/newsroom-index-filters.ts +14 -10
  171. package/lib/newsroom-nav.ts +3 -1
  172. package/lib/newsroom-organizer-nav.ts +37 -0
  173. package/lib/newsroom-section-routing.ts +20 -0
  174. package/lib/oauth-redirect-priority.ts +40 -0
  175. package/lib/papyrus-web-locations.ts +7 -6
  176. package/lib/plugin-settings.ts +11 -0
  177. package/lib/presentation-footer.ts +3 -2
  178. package/lib/pretext-layout.ts +12 -3
  179. package/lib/priming-proof-report.ts +90 -0
  180. package/lib/prospectus-publication-access.ts +53 -0
  181. package/lib/prospectus-session.ts +196 -0
  182. package/lib/prospectus-sign-in-routes.ts +284 -0
  183. package/lib/prospectus-site-identity.ts +303 -0
  184. package/lib/publication-cyclotron.ts +74 -0
  185. package/lib/rate-limit-retry.ts +55 -0
  186. package/lib/reader-base-path.ts +24 -0
  187. package/lib/reader-cache-lifetime.ts +5 -0
  188. package/lib/reader-static-params.ts +85 -4
  189. package/lib/reader-storage-url.ts +9 -12
  190. package/lib/reference-policy.ts +5 -0
  191. package/lib/relevance-decisions.ts +103 -0
  192. package/lib/relevance-status.ts +63 -0
  193. package/lib/renderer.ts +7 -0
  194. package/lib/revalidate-prewarm.ts +53 -0
  195. package/lib/revalidate-secret.ts +59 -0
  196. package/lib/robots-policy.ts +14 -0
  197. package/lib/root-route-redirect.ts +16 -0
  198. package/lib/semantic-graph.ts +7 -0
  199. package/lib/shared-credentials-provider.ts +61 -0
  200. package/lib/signup-csv.ts +24 -0
  201. package/lib/site-brand.ts +59 -0
  202. package/lib/staging-gated-path.ts +23 -0
  203. package/lib/staging-preview-object.ts +21 -5
  204. package/lib/test-editor-override.ts +4 -0
  205. package/lib/video-loading.ts +22 -0
  206. package/lib/video-script.ts +14 -0
  207. package/middleware.ts +11 -19
  208. package/package.json +73 -8
  209. package/renderers/pretext/article-page.tsx +39 -3
  210. package/renderers/pretext/presentation-shared.tsx +113 -0
  211. package/renderers/pretext/presentation-shell.tsx +15 -102
  212. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  213. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  214. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  215. package/routes.manifest.json +123 -12
  216. package/src/sync.mjs +30 -6
  217. package/src/with-papyrus.d.mts +9 -0
  218. package/src/with-papyrus.mjs +48 -3
  219. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  220. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  221. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
  222. package/components/ai-elements/message.tsx +0 -360
@@ -3,7 +3,7 @@ import { generateClient } from "aws-amplify/data";
3
3
  import type { Schema } from "../amplify/data/resource";
4
4
  import type { Article, ArticleImage, ArticleImageAsset, ArticleImageLayout, ArticleImageThemeVariants, ArticleVideoAsset, ArticleVideoThemeVariants } from "./articles";
5
5
  import { withContentLoadTiming } from "./content-load-timing";
6
- import { getAmplifyServerRuntime } from "./amplify-server-runtime";
6
+ import { getAmplifyServerRuntime, runWithGuestAmplifyContext } from "./amplify-server-runtime";
7
7
  import { resolveReaderStorageUrl, signStorageUrl } from "./reader-storage-url";
8
8
  import type { ContentRepository, EditionContent, EditionRouteSummary, ListPublishedEditionsOptions, LoadEditionContentOptions } from "./content-types";
9
9
  import { isDemoAmplifyOutputs } from "./demo-amplify-outputs";
@@ -18,7 +18,10 @@ import {
18
18
  } from "./publication-items";
19
19
  import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type BodyProjectionImage } from "./markus-body";
20
20
  import { SITE_BRAND } from "./site-brand";
21
+ import { shouldFetchVideoScripts } from "./video-mode";
22
+ import { collectVideoScriptTargets, parseVideoScriptRef, videomlItemSlug, type VideoScriptRef } from "./video-script";
21
23
  import { currentContentSource, currentRequestClient, runWithContentSource } from "./content-source-context";
24
+ import { withRateLimitRetry } from "./rate-limit-retry";
22
25
  import { getContentSource } from "./site-env";
23
26
 
24
27
  const DEFAULT_EDITION_SLUG = "current";
@@ -131,10 +134,9 @@ const IMAGE_ROLES: NonNullable<ArticleImageAsset["roles"]> = [
131
134
  "thumbnail",
132
135
  ];
133
136
 
134
- let readerContextClient: DataClient | null = null;
135
137
  let buildTimeClient: DataClient | null = null;
136
138
 
137
- function authMode(): "userPool" | "identityPool" {
139
+ export function authMode(): "userPool" | "identityPool" {
138
140
  return currentContentSource() === "drafts" ? "userPool" : "identityPool";
139
141
  }
140
142
 
@@ -144,27 +146,20 @@ function isVisibleItemStatus(status: string): boolean {
144
146
  }
145
147
 
146
148
  async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise<T> {
147
- const { runWithAmplifyServerContext, config } = getAmplifyServerRuntime();
149
+ const { config } = getAmplifyServerRuntime();
148
150
  const source = getContentSource();
149
151
  if (source === "drafts") {
150
152
  // Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
151
153
  // kept in the request's async context (never a module global) so one visitor's session cannot serve another.
152
154
  const { cookies } = await import("next/headers");
155
+ await cookies();
153
156
  const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
154
157
  return runWithContentSource(source, operation, client);
155
158
  }
156
- return runWithContentSource(source, () =>
157
- runWithAmplifyServerContext({
158
- nextServerContext: null,
159
- operation: async () => {
160
- readerContextClient = generateClient<Schema>({ authMode: authMode() });
161
- try {
162
- return await operation();
163
- } finally {
164
- readerContextClient = null;
165
- }
166
- },
167
- }),
159
+ return withRateLimitRetry(() =>
160
+ runWithGuestAmplifyContext(() =>
161
+ runWithContentSource(source, operation, generateClient<Schema>({ authMode: authMode() })),
162
+ ),
168
163
  );
169
164
  }
170
165
 
@@ -236,6 +231,10 @@ export const graphqlContentRepository: ContentRepository = {
236
231
  return withReaderGraphQLContext(() => loadEditionItem(editionDate, itemSlug));
237
232
  },
238
233
 
234
+ loadVideoScript(targetSlug) {
235
+ return withReaderGraphQLContext(() => loadVideoScriptForTarget(targetSlug));
236
+ },
237
+
239
238
  listArticleSlugs() {
240
239
  return withReaderGraphQLContext(async () => {
241
240
  const items = await listItemsByTypeStatus(`${ARTICLE_TYPE}#${PUBLISHED_STATUS}`);
@@ -250,7 +249,6 @@ export const graphqlContentRepository: ContentRepository = {
250
249
  function getClient(): DataClient {
251
250
  const requestClient = currentRequestClient<DataClient>();
252
251
  if (requestClient) return requestClient;
253
- if (readerContextClient) return readerContextClient;
254
252
 
255
253
  if (!buildTimeClient) {
256
254
  getAmplifyServerRuntime();
@@ -379,6 +377,9 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
379
377
  const sanitizedLayoutPlan = pruneLayoutPlanUnavailableItems(layoutPlan, availableItemSlugs, edition.id);
380
378
  validateEditionLayoutPlanForItems(sanitizedLayoutPlan, items, `PublishedEdition(${edition.id}).layoutPlan`);
381
379
 
380
+ const editionVideo = await normalizeEditionVideoAsset(editionMetadata?.editionVideo);
381
+ const videoScripts = await loadEditionVideoScripts(items, editionVideo);
382
+
382
383
  return {
383
384
  id: edition.id,
384
385
  source: "graphql",
@@ -389,10 +390,38 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
389
390
  items,
390
391
  sections: createEditionSectionPlan(items, edition.metadata),
391
392
  suppressNewsDeskAppendix: editionMetadata?.suppressNewsDeskAppendix === true,
392
- editionVideo: await normalizeEditionVideoAsset(editionMetadata?.editionVideo),
393
+ editionVideo,
394
+ videoScripts,
393
395
  };
394
396
  }
395
397
 
398
+ function videoScriptsEnabled(): boolean {
399
+ return Boolean(SITE_BRAND.videoPlayer) && shouldFetchVideoScripts();
400
+ }
401
+
402
+ async function loadVideoScriptForTarget(targetSlug: string): Promise<VideoScriptRef | null> {
403
+ if (!videoScriptsEnabled()) return null;
404
+ const item = await getItemBySlug(videomlItemSlug(targetSlug));
405
+ if (!item || item.type !== "videoml" || !isVisibleItemStatus(item.status)) return null;
406
+ return parseVideoScriptRef(item);
407
+ }
408
+
409
+ async function loadEditionVideoScripts(
410
+ items: PublicationItem[],
411
+ editionVideo: ArticleVideoAsset | null,
412
+ ): Promise<Record<string, VideoScriptRef> | undefined> {
413
+ if (!videoScriptsEnabled()) return undefined;
414
+ const targets = collectVideoScriptTargets(items, editionVideo !== null);
415
+ const entries = await Promise.all(
416
+ targets.map(async (targetSlug) => {
417
+ const script = await loadVideoScriptForTarget(targetSlug);
418
+ return script ? ([targetSlug, script] as const) : null;
419
+ }),
420
+ );
421
+ const videoScripts = Object.fromEntries(entries.filter((entry): entry is [string, VideoScriptRef] => entry !== null));
422
+ return Object.keys(videoScripts).length > 0 ? videoScripts : undefined;
423
+ }
424
+
396
425
  async function normalizeEditionVideoAsset(value: unknown): Promise<ArticleVideoAsset | null> {
397
426
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
398
427
  const record = value as Record<string, unknown>;
@@ -817,7 +846,7 @@ async function normalizeArticle(item: GraphQLItem, mediaAssets: GraphQLMediaAsse
817
846
 
818
847
  function projectPublishedItemBody(item: GraphQLItem): BodyProjection {
819
848
  if (item.bodyIr === null || item.bodyIr === undefined || item.bodyIr === "") {
820
- throw new BodyIrError(`PublishedItem ${item.slug} has no bodyIr; run: papyrus content convert-bodies`);
849
+ throw new BodyIrError(`PublishedItem ${item.slug} has no bodyIr; run: papyrus ops content convert-bodies`);
821
850
  }
822
851
  return projectBodyIr(item.bodyIr);
823
852
  }
@@ -939,15 +968,20 @@ function parseAttachmentPointer(value: unknown): ContentAttachmentPointer | null
939
968
  };
940
969
  }
941
970
 
942
- function readInlineExcerpt(editorial: unknown): string | null {
971
+ export function readInlineExcerpt(editorial: unknown): string | null {
943
972
  const parsed = parseObjectMetadata(editorial);
944
973
  if (!parsed) return null;
945
974
  const direct = parsed.excerpt;
946
975
  if (typeof direct === "string" && direct.trim()) return direct.trim();
976
+ const custom = parsed.customExcerpt;
977
+ if (typeof custom === "string" && custom.trim()) return custom.trim();
947
978
  const newsroom = parsed.newsroom;
948
979
  if (!newsroom || typeof newsroom !== "object" || Array.isArray(newsroom)) return null;
949
- const nestedExcerpt = (newsroom as Record<string, unknown>).excerpt;
950
- return typeof nestedExcerpt === "string" && nestedExcerpt.trim() ? nestedExcerpt.trim() : null;
980
+ const newsroomRecord = newsroom as Record<string, unknown>;
981
+ const nestedExcerpt = newsroomRecord.excerpt;
982
+ if (typeof nestedExcerpt === "string" && nestedExcerpt.trim()) return nestedExcerpt.trim();
983
+ const nestedCustom = newsroomRecord.customExcerpt;
984
+ return typeof nestedCustom === "string" && nestedCustom.trim() ? nestedCustom.trim() : null;
951
985
  }
952
986
 
953
987
  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);
@@ -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}`;
@@ -0,0 +1,99 @@
1
+ import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
2
+ import type { AssignmentRecord, ReferenceRecord } from "./category-repository";
3
+ import {
4
+ CONTENT_ITEM_STAGES,
5
+ contentItemStageLabel,
6
+ latestVersionPerContentItem,
7
+ type ContentItemVersionRecord,
8
+ } from "./content-item-lifecycle";
9
+ import { getContentItemName } from "./site-brand";
10
+
11
+ export type NativeBoardKind = "contentItem" | "reference" | "assignment";
12
+ export interface NativeBoardIssue extends KanbanIssue {
13
+ kind: NativeBoardKind;
14
+ nativeId: string;
15
+ nativeStatus: string;
16
+ href: string;
17
+ }
18
+
19
+ export function contentItemBoardType(): string {
20
+ return getContentItemName().singular.toLowerCase();
21
+ }
22
+
23
+ function boardTypeForKind(kind: NativeBoardKind): string {
24
+ return kind === "contentItem" ? contentItemBoardType() : kind;
25
+ }
26
+
27
+ /**
28
+ * One card per content item, in the column for its lifecycle stage. Assignments that are work on a content item
29
+ * are counted on that item's card rather than shown as separate cards. References and other work keep their own
30
+ * identity and native status.
31
+ */
32
+ export function projectNewsroomBoard(records: {
33
+ contentItemVersions?: readonly ContentItemVersionRecord[];
34
+ references?: readonly ReferenceRecord[];
35
+ assignments?: readonly AssignmentRecord[];
36
+ }): NativeBoardIssue[] {
37
+ const assignmentCountByContentItem = new Map<string, number>();
38
+ for (const assignment of records.assignments ?? []) {
39
+ if (assignment.itemLineageId) {
40
+ assignmentCountByContentItem.set(assignment.itemLineageId, (assignmentCountByContentItem.get(assignment.itemLineageId) ?? 0) + 1);
41
+ }
42
+ }
43
+ const contentItemType = contentItemBoardType();
44
+ return [
45
+ ...latestVersionPerContentItem(records.contentItemVersions ?? []).map(version => {
46
+ const stage = version.stage ?? "idea";
47
+ const workCount = assignmentCountByContentItem.get(version.lineageId) ?? 0;
48
+ return {
49
+ id: `contentItem:${version.lineageId}`, nativeId: version.lineageId, kind: "contentItem" as const, type: contentItemType,
50
+ title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
51
+ priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
52
+ updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
53
+ };
54
+ }),
55
+ ...(records.references ?? []).filter(row => !row.versionState || row.versionState === "current").map(row => ({
56
+ id: `reference:${row.id}`, nativeId: row.id, kind: "reference" as const, type: "reference",
57
+ title: row.title || row.externalItemId || row.id,
58
+ nativeStatus: row.curationStatus || "pending", status: `reference/${row.curationStatus || "pending"}`,
59
+ priority: 2, description: row.curationStatusReason || undefined,
60
+ updated_at: row.updatedAt || row.importedAt || undefined,
61
+ href: `/newsroom/references/${encodeURIComponent(row.lineageId || row.id)}`,
62
+ })),
63
+ ...(records.assignments ?? []).filter(row => !row.itemLineageId).map(row => ({
64
+ id: `assignment:${row.id}`, nativeId: row.id, kind: "assignment" as const, type: "assignment",
65
+ title: row.title, nativeStatus: row.status, status: `assignment/${row.status}`,
66
+ priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
67
+ description: row.summary || row.brief || undefined,
68
+ created_at: row.createdAt, updated_at: row.updatedAt,
69
+ href: `/newsroom/assignments/${encodeURIComponent(row.id)}`,
70
+ })),
71
+ ];
72
+ }
73
+
74
+ /** Content items always show every lifecycle stage in order; unknown native statuses of other records stay visible. */
75
+ export function newsroomBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
76
+ const contentItemType = contentItemBoardType();
77
+ const kinds: NativeBoardKind[] = ["contentItem", "reference", "assignment"];
78
+ const contentItemStatuses = [
79
+ ...CONTENT_ITEM_STAGES,
80
+ ...new Set(issues.filter(issue => issue.kind === "contentItem").map(issue => issue.nativeStatus)),
81
+ ].filter((stage, index, stages) => stages.indexOf(stage) === index)
82
+ .map(stage => ({ key: `${contentItemType}/${stage}`, name: contentItemStageLabel(stage), category: "native" }));
83
+ const otherStatuses = [...new Set(issues.filter(issue => issue.kind !== "contentItem").map(issue => issue.status))]
84
+ .map(key => ({ key, name: key.replace("/", " · "), category: "native" }));
85
+ const statuses = [...contentItemStatuses, ...otherStatuses];
86
+ return {
87
+ hierarchy: [], types: kinds.map(boardTypeForKind),
88
+ statuses,
89
+ categories: [{ name: "native" }], priorities: {}, type_colors: {},
90
+ workflows: Object.fromEntries(kinds.map(kind => {
91
+ const type = boardTypeForKind(kind);
92
+ return [type, Object.fromEntries(statuses.filter(status => status.key.startsWith(`${type}/`)).map(status => [status.key, []]))];
93
+ })),
94
+ };
95
+ }
96
+
97
+ export function canReadNewsroomBoard(phase: string | undefined): boolean {
98
+ return phase === "ready";
99
+ }
@@ -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 {
@@ -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);
@@ -1,4 +1,5 @@
1
1
  import { newsroomHref, toPublicNewsroomPath } from "./newsroom-base-path";
2
+ import { getContentItemName } from "./site-brand";
2
3
 
3
4
  export type NewsroomNavTabId =
4
5
  | "overview"
@@ -23,13 +24,14 @@ export type NewsroomNavItem = {
23
24
 
24
25
  export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
25
26
  { id: "overview", label: "Overview", detail: "Desk home", href: newsroomHref(), mobilePrimary: true },
26
- { id: "articles", label: "Articles", detail: "Drafts & published", href: newsroomHref("articles"), mobilePrimary: true },
27
+ { id: "articles", label: getContentItemName().plural, detail: "Drafts & published", href: newsroomHref("articles"), mobilePrimary: true },
27
28
  { id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments"), mobilePrimary: true },
28
29
  { id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references"), mobilePrimary: true },
29
30
  { id: "messages", label: "Messages", detail: "Forum", href: newsroomHref("messages") },
30
31
  { id: "insights", label: "Insights", detail: "Research threads", href: newsroomHref("insights") },
31
32
  { id: "topics", label: "Topics", detail: "Taxonomy", href: newsroomHref("topics"), mobilePrimary: true },
32
33
  { id: "concepts", label: "Concepts", detail: "Ontology", href: newsroomHref("concepts") },
34
+ { id: "search", label: "Search", detail: "Knowledge base", href: newsroomHref("search") },
33
35
  { id: "administration", label: "Administration", detail: "Users & policies", href: newsroomHref("administration") },
34
36
  ];
35
37
 
@@ -0,0 +1,37 @@
1
+ import { getContentItemName } from "./site-brand";
2
+
3
+ const contentItemPluralLowercase = getContentItemName().plural.toLowerCase();
4
+ export type OrganizerDestination = {
5
+ key: string; label: string; description: string; path: string;
6
+ access?: "admin"; primary?: boolean; unavailable?: string;
7
+ };
8
+ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
9
+ { title: "Editorial work", items: [
10
+ { key: "overview", label: "Overview", description: "See the desk and choose where to begin.", path: "/newsroom", primary: true },
11
+ { key: "board", label: "Board", description: `Follow ${contentItemPluralLowercase}, sources and assignments across their stages.`, path: "/newsroom/work", primary: true },
12
+ { key: "articles", label: getContentItemName().plural, description: "Write, revise and prepare copy for publication.", path: "/newsroom/articles" },
13
+ { key: "assignments", label: "Assignments", description: "Claim work and follow research and reporting progress.", path: "/newsroom/assignments" },
14
+ { key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
15
+ { key: "insights", label: "Insights", description: "Discuss findings and research threads.", path: "/newsroom/insights" },
16
+ ] },
17
+ { title: "Research & knowledge", items: [
18
+ { key: "references", label: "References", description: "Inspect sources and make editorial inclusion decisions.", path: "/newsroom/references", primary: true },
19
+ { key: "desks", label: "Taxonomy", description: "Maintain topic sets and category versions.", path: "/newsroom/desks" },
20
+ { key: "topics", label: "Topic proposals", description: "Review suggested changes to coverage.", path: "/newsroom/topics" },
21
+ { key: "concepts", label: "Concepts", description: "Explore knowledge entities and relationships.", path: "/newsroom/concepts" },
22
+ { key: "search", label: "Knowledge search", description: "Find evidence across the knowledge base.", path: "/newsroom/search" },
23
+ ] },
24
+ { title: "Administration", items: [
25
+ { key: "policies", label: "Policies", description: "Set publication and category guidance.", path: "/newsroom/administration/policies" },
26
+ { key: "sections", label: "Sections", description: "Organize the publication’s sections.", path: "/newsroom/administration/sections" },
27
+ { key: "procedures", label: "Procedures", description: "Manage procedure definitions and versions.", path: "/newsroom/administration/procedures", access: "admin" },
28
+ { key: "users", label: "Users & identity merging", description: "Review people, roles and identity merges.", path: "/newsroom/administration/users", access: "admin" },
29
+ { key: "administration", label: "Administration", description: "Open the existing CMS settings workspace.", path: "/newsroom/administration" },
30
+ ] },
31
+ ];
32
+ const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
33
+ export const ORGANIZER_PRIMARY_NAV = ["overview", "board", "references", "messages"].map(key => all.find(item => item.key === key)!);
34
+
35
+ export const ORGANIZER_SETTINGS_DESTINATION = {
36
+ label: "Settings", path: "/newsroom/settings", description: "Theme, motion and interface preferences.",
37
+ } as const;
@@ -0,0 +1,20 @@
1
+ import type { NewsDeskTab } from "../components/topic-steering-workspace";
2
+
3
+ /** The organizer restores the existing taxonomy editor; legacy sites retain their aliases. */
4
+ export function resolveNewsDeskTab(
5
+ sectionValue: string | string[] | null | undefined,
6
+ legacyTabValue: string | string[] | null | undefined,
7
+ organizer = false,
8
+ ): NewsDeskTab {
9
+ const section = Array.isArray(sectionValue) ? sectionValue[0] : sectionValue;
10
+ const legacyTab = Array.isArray(legacyTabValue) ? legacyTabValue[0] : legacyTabValue;
11
+ const raw = section ?? (legacyTab === "categories" ? "topics" : legacyTab);
12
+ const value = raw === "users" || raw === "doctrine" ? "administration"
13
+ : raw === "desks" && !organizer ? "topics" : raw;
14
+ switch (value) {
15
+ case "desks": case "administration": case "topics": case "concepts":
16
+ case "references": case "insights": case "messages": case "assignments": case "search":
17
+ return value;
18
+ default: return "overview";
19
+ }
20
+ }
@@ -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