@anthusai/papyrus 1.0.0-next.8 → 1.0.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 (209) 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 +42 -80
  6. package/amplify/data/resource.ts +42 -80
  7. package/amplify/data/schema.js +45 -60
  8. package/amplify/data/schema.ts +47 -60
  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 +143 -1
  15. package/amplify/functions/content-actions/requirements.txt +2 -1
  16. package/amplify/functions/content-actions/resource.js +6 -1
  17. package/amplify/functions/content-actions/resource.ts +9 -2
  18. package/amplify/functions/renditions/handler.py +120 -0
  19. package/amplify/functions/renditions/requirements.txt +4 -0
  20. package/amplify/functions/shared/python-bundle.js +42 -2
  21. package/amplify/functions/shared/python-bundle.ts +47 -2
  22. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  23. package/amplify/inbound-email/ses-intake.js +86 -0
  24. package/amplify/inbound-email/ses-intake.ts +110 -0
  25. package/amplify/plugins/registry.js +19 -0
  26. package/amplify/plugins/registry.ts +23 -0
  27. package/amplify/plugins/signup/function/handler.py +200 -0
  28. package/amplify/plugins/signup/function/resource.js +27 -0
  29. package/amplify/plugins/signup/function/resource.ts +35 -0
  30. package/amplify/plugins/signup/papyrus.plugin.js +59 -0
  31. package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
  32. package/amplify/plugins/signup/schema.js +40 -0
  33. package/amplify/plugins/signup/schema.ts +41 -0
  34. package/amplify/plugins/signup/waf.js +48 -0
  35. package/amplify/plugins/signup/waf.ts +51 -0
  36. package/amplify/plugins/types.js +1 -0
  37. package/amplify/plugins/types.ts +39 -0
  38. package/amplify/plugins/wire.js +24 -0
  39. package/amplify/plugins/wire.ts +26 -0
  40. package/amplify/publication/default-publication.js +18 -0
  41. package/amplify/publication/default-publication.ts +20 -0
  42. package/amplify/publication/invalidation-alarm.js +26 -0
  43. package/amplify/publication/invalidation-alarm.ts +30 -0
  44. package/amplify/publication/redirect-and-index.js +37 -0
  45. package/amplify/publication/renditions-function.js +74 -0
  46. package/amplify/publication/renditions-function.ts +112 -0
  47. package/amplify/publication/renditions-settings.js +51 -0
  48. package/amplify/publication/renditions-settings.ts +58 -0
  49. package/amplify/publication/reproject-on-deploy.js +73 -0
  50. package/amplify/publication/reproject-on-deploy.ts +78 -0
  51. package/amplify/publication/s3-projection-settings.js +67 -0
  52. package/amplify/publication/s3-projection-settings.ts +101 -0
  53. package/amplify/publication/s3-projection.js +151 -0
  54. package/amplify/publication/s3-projection.ts +201 -0
  55. package/amplify/resolver-waves.js +26 -0
  56. package/amplify/resolver-waves.ts +34 -0
  57. package/amplify/revalidate-invalidation-settings.js +30 -0
  58. package/amplify/revalidate-invalidation-settings.ts +35 -0
  59. package/amplify/revalidate-secret-policy.js +13 -0
  60. package/amplify/revalidate-secret-policy.ts +19 -0
  61. package/amplify/site-backend-config.js +65 -1
  62. package/amplify/site-backend-config.ts +153 -2
  63. package/amplify/site-backend-names.js +103 -0
  64. package/amplify/site-backend-names.ts +166 -0
  65. package/amplify/site-backend.js +143 -176
  66. package/amplify/site-backend.ts +181 -201
  67. package/amplify/storage-backups/construct.js +31 -0
  68. package/amplify/storage-backups/construct.ts +47 -0
  69. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
  70. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  71. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
  72. package/app/[year]/[month]/[day]/page.tsx +1 -1
  73. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
  74. package/app/api/archive/editions/route.ts +1 -1
  75. package/app/api/cyclotron/priming-proof/route.ts +11 -0
  76. package/app/api/media/[...path]/route.ts +5 -16
  77. package/app/api/revalidate/route.ts +15 -7
  78. package/app/archive/page.tsx +1 -1
  79. package/app/articles/[slug]/page.tsx +7 -5
  80. package/app/blog-rhythm.css +278 -0
  81. package/app/cyclotron/page.tsx +13 -0
  82. package/app/globals.css +2 -0
  83. package/app/layout.tsx +16 -0
  84. package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
  85. package/app/newsroom/[section]/page.tsx +6 -3
  86. package/app/newsroom/layout.tsx +4 -9
  87. package/app/newsroom/settings/page.tsx +6 -0
  88. package/app/newsroom/work/page.tsx +10 -0
  89. package/app/page.tsx +22 -8
  90. package/app/robots.ts +2 -5
  91. package/app/tailwind.css +1 -0
  92. package/bin/papyrus-infra.mjs +3 -3
  93. package/components/amplify-client-provider.tsx +5 -22
  94. package/components/archive-shell.tsx +2 -22
  95. package/components/article-video.tsx +152 -5
  96. package/components/blog-page-background.tsx +23 -6
  97. package/components/news-desk-page.tsx +4 -15
  98. package/components/news-desk-taxonomy-client.ts +90 -3
  99. package/components/newspaper.tsx +6 -2
  100. package/components/newsroom-access-gate.tsx +2 -1
  101. package/components/newsroom-board-view.tsx +99 -0
  102. package/components/newsroom-client-shell.tsx +12 -0
  103. package/components/newsroom-curation-provider.tsx +17 -0
  104. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  105. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  106. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  107. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  108. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  109. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  110. package/components/newsroom-newsprint-board.css +48 -0
  111. package/components/newsroom-ops-overview.tsx +28 -11
  112. package/components/newsroom-ops-shell.tsx +7 -1
  113. package/components/newsroom-organizer-shell.tsx +130 -0
  114. package/components/newsroom-organizer.css +63 -0
  115. package/components/newsroom-references-view.tsx +92 -8
  116. package/components/papyrus-console-shell.tsx +4 -2
  117. package/components/pictogram-figure.tsx +9 -2
  118. package/components/presentation-header.tsx +135 -0
  119. package/components/presentation-rhythm-hrule.tsx +17 -0
  120. package/components/priming-proof-page.tsx +92 -0
  121. package/components/publication-cyclotron-panel.tsx +57 -0
  122. package/components/reader-auth-control.tsx +8 -5
  123. package/components/reference-curation-controls.tsx +77 -0
  124. package/components/reference-cyclotron-status.tsx +109 -0
  125. package/components/reference-priming.tsx +18 -0
  126. package/components/reference-relevance-review.tsx +75 -0
  127. package/components/settings-page.tsx +2 -1
  128. package/components/signup-entries-panel.tsx +94 -0
  129. package/components/topic-steering-workspace.tsx +78 -6
  130. package/components/use-blog-header-obstacles.ts +81 -0
  131. package/components/use-brand-fonts-ready.ts +28 -0
  132. package/components/use-near-viewport.ts +30 -0
  133. package/components/use-rhythm-overlay.ts +29 -0
  134. package/infra/amplify-app-shell.js +219 -71
  135. package/infra/amplify-app-shell.ts +231 -77
  136. package/infra/build-specs.js +23 -8
  137. package/infra/build-specs.ts +27 -11
  138. package/infra/site-config.js +277 -34
  139. package/infra/site-config.ts +340 -43
  140. package/lib/amplify-outputs-path.ts +5 -1
  141. package/lib/amplify-server-runtime.ts +36 -1
  142. package/lib/approved-priming-proof-reports.ts +6 -0
  143. package/lib/blog-feature-solver.ts +459 -0
  144. package/lib/blog-rhythm.ts +134 -0
  145. package/lib/brand-fonts-watch.ts +43 -0
  146. package/lib/brand-fonts.ts +112 -0
  147. package/lib/cached-content-repository.ts +12 -0
  148. package/lib/category-repository.ts +43 -93
  149. package/lib/content-item-lifecycle.ts +55 -0
  150. package/lib/content-repository.ts +3 -0
  151. package/lib/content-types.ts +4 -0
  152. package/lib/curation-client.ts +61 -0
  153. package/lib/edition-routes.ts +31 -29
  154. package/lib/graphql-content-repository.ts +56 -22
  155. package/lib/media-route-response.ts +45 -0
  156. package/lib/newspaper-layout.ts +3 -2
  157. package/lib/newsroom-base-path.ts +12 -3
  158. package/lib/newsroom-board.ts +99 -0
  159. package/lib/newsroom-category-drilldown.ts +3 -2
  160. package/lib/newsroom-forum-routes.ts +6 -4
  161. package/lib/newsroom-index-filters.ts +14 -10
  162. package/lib/newsroom-nav.ts +3 -1
  163. package/lib/newsroom-organizer-nav.ts +37 -0
  164. package/lib/newsroom-section-routing.ts +20 -0
  165. package/lib/oauth-redirect-priority.ts +40 -0
  166. package/lib/papyrus-web-locations.ts +7 -6
  167. package/lib/plugin-settings.ts +11 -0
  168. package/lib/presentation-footer.ts +3 -2
  169. package/lib/pretext-layout.ts +12 -3
  170. package/lib/priming-proof-report.ts +89 -0
  171. package/lib/prospectus-site-identity.ts +303 -0
  172. package/lib/publication-cyclotron.ts +74 -0
  173. package/lib/rate-limit-retry.ts +55 -0
  174. package/lib/reader-base-path.ts +24 -0
  175. package/lib/reader-cache-lifetime.ts +5 -0
  176. package/lib/reader-static-params.ts +85 -4
  177. package/lib/reader-storage-url.ts +9 -12
  178. package/lib/reference-policy.ts +5 -0
  179. package/lib/relevance-decisions.ts +103 -0
  180. package/lib/relevance-status.ts +63 -0
  181. package/lib/renderer.ts +7 -0
  182. package/lib/revalidate-prewarm.ts +53 -0
  183. package/lib/revalidate-secret.ts +59 -0
  184. package/lib/robots-policy.ts +14 -0
  185. package/lib/root-route-redirect.ts +16 -0
  186. package/lib/semantic-graph.ts +7 -0
  187. package/lib/shared-credentials-provider.ts +61 -0
  188. package/lib/signup-csv.ts +24 -0
  189. package/lib/site-brand.ts +59 -0
  190. package/lib/staging-gated-path.ts +23 -0
  191. package/lib/staging-preview-object.ts +21 -5
  192. package/lib/test-editor-override.ts +4 -0
  193. package/lib/video-loading.ts +22 -0
  194. package/lib/video-script.ts +14 -0
  195. package/middleware.ts +11 -19
  196. package/package.json +64 -1
  197. package/renderers/pretext/article-page.tsx +39 -3
  198. package/renderers/pretext/presentation-shared.tsx +113 -0
  199. package/renderers/pretext/presentation-shell.tsx +15 -102
  200. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  201. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  202. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  203. package/routes.manifest.json +68 -12
  204. package/src/sync.mjs +30 -6
  205. package/src/with-papyrus.d.mts +9 -0
  206. package/src/with-papyrus.mjs +48 -3
  207. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  208. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  209. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
package/app/page.tsx CHANGED
@@ -2,6 +2,7 @@ import { getSiteRenderer } from "../lib/site-renderer";
2
2
  import { contentRepository, getScenarioIdParam } from "../lib/content-repository";
3
3
  import type { EditionContent } from "../lib/content-types";
4
4
  import { createEditionSectionPlan } from "../lib/edition-sections";
5
+ import { getCachedLatestPublishedEdition, loadCachedEditionContent } from "../lib/cached-content-repository";
5
6
  import { getEditionDatePath } from "../lib/edition-routes";
6
7
  import {
7
8
  EMPTY_EDITION_PLACEHOLDER_SLUG,
@@ -15,7 +16,10 @@ import {
15
16
  } from "../lib/public-placeholder-config";
16
17
  import type { PublicationItem } from "../lib/publication-items";
17
18
  import { SITE_BRAND, rootRoute } from "../lib/site-brand";
18
- import { redirect } from "next/navigation";
19
+ import { notFound, redirect } from "next/navigation";
20
+ import { getReaderBasePath } from "../lib/reader-base-path";
21
+ import { buildRootRedirectTarget } from "../lib/root-route-redirect";
22
+ import { NewsroomClientShell } from "../components/newsroom-client-shell";
19
23
  import NewsDeskRootPage, { type NewsDeskRootSearchParams } from "./newsroom/page";
20
24
 
21
25
  export const dynamic = "force-dynamic";
@@ -37,12 +41,22 @@ export default async function Home({ searchParams }: HomePageProps) {
37
41
  // co-hosts reader + CMS uses the default "reader" (render the publication
38
42
  // home page here). A CMS-only site points the root at the newsroom or
39
43
  // anywhere else. Preserve the scenario and OAuth-callback paths.
40
- if (rootRoute.kind === "newsroom" && !scenarioId) {
41
- return <NewsDeskRootPage searchParams={searchParams} />;
44
+ const publicationOwnsRoot = getReaderBasePath() !== "";
45
+
46
+ if (!publicationOwnsRoot && rootRoute.kind === "newsroom" && !scenarioId) {
47
+ return (
48
+ <NewsroomClientShell>
49
+ <NewsDeskRootPage searchParams={searchParams} />
50
+ </NewsroomClientShell>
51
+ );
52
+ }
53
+
54
+ if (!publicationOwnsRoot && rootRoute.kind === "redirect") {
55
+ redirect(buildRootRedirectTarget(rootRoute.destination, resolvedSearchParams));
42
56
  }
43
57
 
44
- if (rootRoute.kind === "redirect" && !scenarioId && !hasOAuthRedirectParams(resolvedSearchParams)) {
45
- redirect(rootRoute.destination);
58
+ if (SITE_BRAND.renderer.kind === "markus") {
59
+ notFound();
46
60
  }
47
61
 
48
62
  // Resolved only on the reader path: Markus brands (renderer "markus") have
@@ -64,7 +78,7 @@ export default async function Home({ searchParams }: HomePageProps) {
64
78
  );
65
79
  }
66
80
 
67
- const latestEdition = await contentRepository.getLatestPublishedEdition();
81
+ const latestEdition = await getCachedLatestPublishedEdition();
68
82
  if (latestEdition) redirect(getEditionDatePath(latestEdition.editionDate));
69
83
  return <siteRenderer.renderEdition content={createEmptyGraphQLEdition()} />;
70
84
  }
@@ -75,9 +89,9 @@ export default async function Home({ searchParams }: HomePageProps) {
75
89
  }
76
90
 
77
91
  async function loadLatestGraphQLEdition(): Promise<EditionContent | null> {
78
- const latestEdition = await contentRepository.getLatestPublishedEdition();
92
+ const latestEdition = await getCachedLatestPublishedEdition();
79
93
  if (!latestEdition) return null;
80
- return contentRepository.loadEditionContent({ editionDate: latestEdition.editionDate });
94
+ return loadCachedEditionContent({ editionDate: latestEdition.editionDate });
81
95
  }
82
96
 
83
97
  async function loadFirstPublishedEditionPath(): Promise<string | undefined> {
package/app/robots.ts CHANGED
@@ -1,11 +1,8 @@
1
1
  import type { MetadataRoute } from "next";
2
- import { isIndexable } from "../lib/site-env";
2
+ import { buildRobots } from "../lib/robots-policy";
3
3
 
4
4
  export const dynamic = "force-dynamic";
5
5
 
6
6
  export default function robots(): MetadataRoute.Robots {
7
- if (isIndexable()) {
8
- return { rules: { userAgent: "*", allow: "/" } };
9
- }
10
- return { rules: { userAgent: "*", disallow: "/" } };
7
+ return buildRobots();
11
8
  }
package/app/tailwind.css CHANGED
@@ -1,4 +1,5 @@
1
1
  @import "tailwindcss";
2
+ @source "../../../cyclotron/trace-ui/src/**/*.{ts,tsx}";
2
3
  @source "../components/**/*.{ts,tsx}";
3
4
  @source "../app/**/*.{ts,tsx}";
4
5
  @source "../lib/**/*.{ts,tsx}";
@@ -11,7 +11,7 @@ import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import { createRequire } from "node:module";
13
13
  import { buildSpecFor } from "../infra/build-specs.js";
14
- import { parseSiteConfig } from "../infra/site-config.js";
14
+ import { parseSiteConfig, resolveStackName } from "../infra/site-config.js";
15
15
 
16
16
  const USAGE = [
17
17
  "Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
@@ -89,7 +89,7 @@ const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib"
89
89
  const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
90
90
  new AmplifyAppShellStack(app, `AmplifyAppShell-${config.siteId}`, config, {
91
91
  env: { account: opt("account", "335163751677"), region: opt("region", "us-east-1") },
92
- stackName: `amplify-app-shell-${config.siteId}`,
92
+ stackName: resolveStackName(config),
93
93
  });
94
94
  app.synth();
95
- console.log(`synthesized amplify-app-shell-${config.siteId} -> ${path.resolve(opt("out", "cdk.out"))}`);
95
+ console.log(`synthesized ${resolveStackName(config)} -> ${path.resolve(opt("out", "cdk.out"))}`);
@@ -8,11 +8,14 @@ import { CookieStorage, Hub } from "aws-amplify/utils";
8
8
  import { useEffect } from "react";
9
9
  import { assertSandboxAmplifyOutputsForDev } from "../lib/amplify-outputs-guard";
10
10
  import amplifyOutputs from "papyrus-amplify-outputs";
11
+ import { getPublicNewsroomBasePath } from "../lib/newsroom-base-path";
12
+ import { prioritizeRedirectUrlsForOrigin } from "../lib/oauth-redirect-priority";
11
13
 
12
14
  let configured = false;
13
15
 
14
16
  export function configureAmplifyClient() {
15
17
  if (configured) return;
18
+ if (typeof window === "undefined") return;
16
19
  assertSandboxAmplifyOutputsForDev(amplifyOutputs as Record<string, unknown>);
17
20
  Amplify.configure(prioritizeCurrentOrigin(amplifyOutputs as ResourcesConfig), { ssr: true });
18
21
  applyClientTokenStorageForCurrentProtocol();
@@ -72,14 +75,10 @@ function prioritizeCurrentOrigin(config: ResourcesConfig): ResourcesConfig {
72
75
  const auth = (config as { auth?: { oauth?: Record<string, unknown> } }).auth;
73
76
  const oauth = auth?.oauth;
74
77
  if (!auth || !oauth) return config;
75
- const origin = normalizeUrlOrigin(window.location.origin);
76
78
  const reorder = (urls: unknown) => {
77
79
  if (!Array.isArray(urls)) return urls;
78
- const normalized = urls.filter((value): value is string => typeof value === "string");
79
- const head = normalized.filter((value) => normalizeUrlOrigin(value) === origin);
80
- if (!head.length) return normalized;
81
- const tail = normalized.filter((value) => normalizeUrlOrigin(value) !== origin);
82
- return [...head, ...tail];
80
+ const strings = urls.filter((value): value is string => typeof value === "string");
81
+ return prioritizeRedirectUrlsForOrigin(strings, window.location.origin, getPublicNewsroomBasePath());
83
82
  };
84
83
  return {
85
84
  ...config,
@@ -111,22 +110,6 @@ function redirectLoopbackToLocalhost(): void {
111
110
  window.location.replace(target);
112
111
  }
113
112
 
114
- function normalizeUrlOrigin(value: string): string {
115
- const trimmed = value.replace(/\/+$/, "");
116
- try {
117
- const parsed = new URL(trimmed);
118
- const hostname = normalizeLoopbackHostname(parsed.hostname);
119
- return `${parsed.protocol}//${hostname}${parsed.port ? `:${parsed.port}` : ""}`;
120
- } catch {
121
- return trimmed;
122
- }
123
- }
124
-
125
- function normalizeLoopbackHostname(hostname: string): string {
126
- if (hostname === "127.0.0.1" || hostname === "::1") return "localhost";
127
- return hostname;
128
- }
129
-
130
113
  function extractAuthFailureDetail(error: unknown): string | null {
131
114
  if (!error) return null;
132
115
  if (typeof error === "string") return error;
@@ -1,27 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  import type { ReactNode } from "react";
4
- import { useEffect, useState } from "react";
4
+ import { useRhythmOverlay } from "./use-rhythm-overlay";
5
5
 
6
6
  type ArchiveShellProps = {
7
7
  children: ReactNode;
8
8
  };
9
9
 
10
10
  export function ArchiveShell({ children }: ArchiveShellProps) {
11
- const [showRhythmOverlay, setShowRhythmOverlay] = useState(false);
12
-
13
- useEffect(() => {
14
- const handleKeyDown = (event: KeyboardEvent) => {
15
- if (isEditableEventTarget(event.target)) return;
16
- if ((event.key === "=" || event.code === "Equal") && event.ctrlKey && !event.metaKey && !event.altKey) {
17
- event.preventDefault();
18
- setShowRhythmOverlay((current) => !current);
19
- }
20
- };
21
-
22
- window.addEventListener("keydown", handleKeyDown);
23
- return () => window.removeEventListener("keydown", handleKeyDown);
24
- }, []);
11
+ const showRhythmOverlay = useRhythmOverlay();
25
12
 
26
13
  return (
27
14
  <main
@@ -32,10 +19,3 @@ export function ArchiveShell({ children }: ArchiveShellProps) {
32
19
  </main>
33
20
  );
34
21
  }
35
-
36
- function isEditableEventTarget(target: EventTarget | null): boolean {
37
- if (!(target instanceof HTMLElement)) return false;
38
- const tagName = target.tagName.toLowerCase();
39
- if (tagName === "input" || tagName === "textarea" || tagName === "select") return true;
40
- return target.isContentEditable || target.closest("[contenteditable='true']") !== null;
41
- }
@@ -1,11 +1,14 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
4
+ import { Pause, Play } from "lucide-react";
4
5
  import type { ArticleVideoAsset } from "../lib/articles";
5
6
  import { resolveThemedVideoSrc } from "../lib/themed-image";
6
7
  import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
7
8
  import { getSiteBrand } from "../lib/site-brand";
8
9
  import { normalizeDevPreviewDsl, type VideoScriptRef } from "../lib/video-script";
10
+ import { useNearViewport } from "./use-near-viewport";
11
+ import { planVideoLoading, videoElementKey } from "../lib/video-loading";
9
12
  import { resolveVideoMode, resolveVideoModeFromEnv, type VideoMode } from "../lib/video-mode";
10
13
 
11
14
  type ArticleVideoFigureProps = {
@@ -17,6 +20,7 @@ type ArticleVideoFigureProps = {
17
20
  };
18
21
 
19
22
  const PREVIEW_STORAGE_PREFIX = "papyrus-video-preview:";
23
+ const VIDEO_LOAD_ROOT_MARGIN = "200px 0px";
20
24
 
21
25
  export function ArticleVideoFigure({
22
26
  video,
@@ -32,6 +36,10 @@ export function ArticleVideoFigure({
32
36
  const [duration, setDuration] = useState(0);
33
37
  const [previewReady, setPreviewReady] = useState(false);
34
38
  const iframeRef = useRef<HTMLIFrameElement>(null);
39
+ const videoRef = useRef<HTMLVideoElement>(null);
40
+ const figureRef = useRef<HTMLElement>(null);
41
+ const isNearViewport = useNearViewport(figureRef, VIDEO_LOAD_ROOT_MARGIN);
42
+ const isFramed = getSiteBrand().videoPlayer === "framed";
35
43
 
36
44
  useEffect(() => {
37
45
  setHasHydrated(true);
@@ -41,6 +49,12 @@ export function ArticleVideoFigure({
41
49
  // Match SSR to the default dark MP4 in `video.src` until after hydration.
42
50
  const resolvedTheme = hasHydrated ? theme : "dark";
43
51
  const src = resolveThemedVideoSrc(video.src, video.themeVariants, resolvedTheme);
52
+ const loadingPlan = planVideoLoading({
53
+ hasHydrated,
54
+ isNearViewport,
55
+ hasPoster: Boolean(video.posterSrc),
56
+ });
57
+ const videoKey = videoElementKey(loadingPlan.attachSource, src);
44
58
  const usePreview = videoMode === "preview" && Boolean(videoScript?.dsl);
45
59
  const previewDsl = videoScript?.dsl
46
60
  ? normalizeDevPreviewDsl(videoScript.dsl, getSiteBrand().video?.sceneComponents)
@@ -91,6 +105,58 @@ export function ArticleVideoFigure({
91
105
  setCurrentTime(clamped);
92
106
  }, []);
93
107
 
108
+ const toggleNativePlayback = useCallback(() => {
109
+ const element = videoRef.current;
110
+ if (!element) return;
111
+ if (element.paused) void element.play().catch(() => setIsPlaying(false));
112
+ else element.pause();
113
+ }, []);
114
+
115
+ const seekNative = useCallback((nextTime: number) => {
116
+ const element = videoRef.current;
117
+ if (!element) return;
118
+ element.currentTime = Math.max(0, Number.isFinite(nextTime) ? nextTime : 0);
119
+ setCurrentTime(element.currentTime);
120
+ }, []);
121
+
122
+ const framedControls = (
123
+ <FramedVideoControls
124
+ currentTime={currentTime}
125
+ duration={duration}
126
+ isPlaying={isPlaying}
127
+ onSeek={usePreview ? seekPreview : seekNative}
128
+ onToggle={usePreview ? togglePreviewPlayback : toggleNativePlayback}
129
+ />
130
+ );
131
+
132
+ if (isFramed && usePreview) {
133
+ return (
134
+ <figure
135
+ className={`${figureClassName} article-video--framed article-video--preview`}
136
+ data-media-type="videoml-preview"
137
+ data-video-theme={resolvedTheme}
138
+ data-video-mode={videoMode}
139
+ >
140
+ <div className="article-video__media">
141
+ {hasHydrated ? (
142
+ <iframe
143
+ key={`${slug}-${resolvedTheme}-${src}`}
144
+ ref={iframeRef}
145
+ className="article-video__preview-frame"
146
+ src={previewSrc}
147
+ title={video.alt}
148
+ onLoad={() => setPreviewReady(true)}
149
+ />
150
+ ) : null}
151
+ </div>
152
+ {framedControls}
153
+ <span className="sr-only" data-video-slug={slug}>
154
+ {video.alt}
155
+ </span>
156
+ </figure>
157
+ );
158
+ }
159
+
94
160
  if (usePreview) {
95
161
  return (
96
162
  <figure
@@ -133,18 +199,56 @@ export function ArticleVideoFigure({
133
199
  );
134
200
  }
135
201
 
202
+ if (isFramed) {
203
+ return (
204
+ <figure
205
+ ref={figureRef}
206
+ className={`${figureClassName} article-video--framed`}
207
+ data-media-type="video"
208
+ data-video-theme={resolvedTheme}
209
+ data-video-mode={videoMode}
210
+ >
211
+ <div className="article-video__media">
212
+ <video
213
+ ref={videoRef}
214
+ playsInline
215
+ preload={loadingPlan.preload}
216
+ poster={video.posterSrc}
217
+ aria-label={video.alt}
218
+ className="article-video__player"
219
+ key={videoKey}
220
+ onPlay={() => setIsPlaying(true)}
221
+ onPause={() => setIsPlaying(false)}
222
+ onEnded={() => setIsPlaying(false)}
223
+ onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
224
+ onDurationChange={(event) => {
225
+ const nextDuration = event.currentTarget.duration;
226
+ if (Number.isFinite(nextDuration) && nextDuration > 0) setDuration(nextDuration);
227
+ }}
228
+ >
229
+ {loadingPlan.attachSource ? <source src={src} type="video/mp4" /> : null}
230
+ </video>
231
+ </div>
232
+ {framedControls}
233
+ <span className="sr-only" data-video-slug={slug}>
234
+ {video.alt}
235
+ </span>
236
+ </figure>
237
+ );
238
+ }
239
+
136
240
  return (
137
- <figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
241
+ <figure ref={figureRef} className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
138
242
  <video
139
243
  controls
140
244
  playsInline
141
- preload="metadata"
245
+ preload={loadingPlan.preload}
142
246
  poster={video.posterSrc}
143
247
  aria-label={video.alt}
144
248
  className="article-video__player"
145
- key={hasHydrated ? src : "ssr"}
249
+ key={videoKey}
146
250
  >
147
- <source src={src} type="video/mp4" />
251
+ {loadingPlan.attachSource ? <source src={src} type="video/mp4" /> : null}
148
252
  </video>
149
253
  {video.caption ? <figcaption>{video.caption}</figcaption> : null}
150
254
  {video.credit ? <p className="article-video__credit">{video.credit}</p> : null}
@@ -154,3 +258,46 @@ export function ArticleVideoFigure({
154
258
  </figure>
155
259
  );
156
260
  }
261
+
262
+ function FramedVideoControls({
263
+ currentTime,
264
+ duration,
265
+ isPlaying,
266
+ onSeek,
267
+ onToggle,
268
+ }: {
269
+ currentTime: number;
270
+ duration: number;
271
+ isPlaying: boolean;
272
+ onSeek: (nextTime: number) => void;
273
+ onToggle: () => void;
274
+ }) {
275
+ const seekProgress = duration > 0 ? (currentTime / duration) * 100 : 0;
276
+ return (
277
+ <div className="article-video__cta" data-playing={isPlaying ? "true" : "false"}>
278
+ <button type="button" className="article-video__cta-toggle" onClick={onToggle} aria-pressed={isPlaying}>
279
+ <span className="article-video__cta-label">
280
+ <span className="article-video__cta-label-sizer" aria-hidden="true">
281
+ Pause Video
282
+ </span>
283
+ <span className="article-video__cta-label-text">{isPlaying ? "Pause Video" : "Play Video"}</span>
284
+ </span>
285
+ <span className="article-video__cta-icon" aria-hidden="true">
286
+ {isPlaying ? <Pause /> : <Play />}
287
+ </span>
288
+ </button>
289
+ <input
290
+ className="article-video__cta-seek"
291
+ type="range"
292
+ min={0}
293
+ max={duration || 0}
294
+ step={0.1}
295
+ value={Math.min(currentTime, duration || 0)}
296
+ onChange={(event) => onSeek(Number(event.target.value))}
297
+ aria-label="Seek video"
298
+ disabled={!duration}
299
+ style={{ "--seek-progress": `${seekProgress}%` } as CSSProperties}
300
+ />
301
+ </div>
302
+ );
303
+ }
@@ -2,18 +2,35 @@
2
2
 
3
3
  import type { RefObject } from "react";
4
4
 
5
+ /** A rectangle, in the background layer's own coordinates, that header text occupies. */
6
+ export type BlogHeaderObstacle = {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ };
12
+
13
+ /** Vertical rhythm grid the rhythm blog layout is built on. */
14
+ export type BlogBackgroundRhythm = {
15
+ rowHeight: number;
16
+ paintBuffer: number;
17
+ paintHeight: number;
18
+ };
19
+
5
20
  export type BlogPageBackgroundProps = {
6
21
  pageRef: RefObject<HTMLElement | null>;
22
+ /** Rhythm blog layout only: header text boxes the artwork should keep clear of. */
23
+ headerObstacles?: BlogHeaderObstacle[];
24
+ /** Rhythm blog layout only: the rhythm grid, so artwork can align to it. */
25
+ rhythm?: BlogBackgroundRhythm;
7
26
  };
8
27
 
9
28
  /**
10
29
  * Default (no-op) blog page background for publications without a bespoke
11
- * backdrop. Threat Intelligence supplies its own animated defense-graph
12
- * background (publications/threat_intelligence/blog-defense/page-background.tsx);
13
- * every other publication gets no decorative background layer. This is what
14
- * `renderers/pretext` falls back to for any publication that isn't Threat
15
- * Intelligence, so the blog layout loads cleanly without depending on TI's
16
- * package.
30
+ * backdrop. A publication supplies header artwork through the
31
+ * `components.BlogPageBackground` brand slot; with the rhythm blog layout the
32
+ * slot also receives `headerObstacles` and `rhythm`. This is what
33
+ * `renderers/pretext` falls back to when no slot component is registered.
17
34
  */
18
35
  export function BlogPageBackground(_props: BlogPageBackgroundProps) {
19
36
  return null;
@@ -1,3 +1,4 @@
1
+ import { resolveNewsDeskTab } from "../lib/newsroom-section-routing";
1
2
  import { NewsDeskWorkspace, type NewsDeskTab } from "./topic-steering-workspace";
2
3
  import {
3
4
  createDemoCategorySteeringDashboard,
@@ -41,7 +42,8 @@ export type NewsDeskPageProps = {
41
42
 
42
43
  export async function NewsDeskPage({ section: routeSection, sectionPageId, selectionPath, searchParams }: NewsDeskPageProps) {
43
44
  const resolvedSearchParams = await searchParams;
44
- const initialTab = parseNewsDeskTab(routeSection ?? getSearchParam(resolvedSearchParams, "section"), getSearchParam(resolvedSearchParams, "tab"));
45
+ const brand = resolveActiveSiteBrand();
46
+ const initialTab = resolveNewsDeskTab(routeSection ?? getSearchParam(resolvedSearchParams, "section"), getSearchParam(resolvedSearchParams, "tab"), brand.newsroomLayout === "organizer");
45
47
  const routeSelection = parseRouteSelection(initialTab, selectionPath);
46
48
  const initialSelection = {
47
49
  assignment: routeSelection.assignment ?? getFirstSearchParam(resolvedSearchParams, "assignment"),
@@ -63,7 +65,6 @@ export async function NewsDeskPage({ section: routeSection, sectionPageId, selec
63
65
  proposal: getFirstSearchParam(resolvedSearchParams, "proposal"),
64
66
  };
65
67
  const useDemoDashboard = getFirstSearchParam(resolvedSearchParams, "demo") === "1";
66
- const brand = resolveActiveSiteBrand();
67
68
  const analysisProfiles = await loadAnalysisProfileSummaries(getBrandAnalysisProfilesPath(brand));
68
69
  const configuredCorpora = await loadConfiguredCorpusSummaries(getBrandSteeringConfigPath(brand));
69
70
  const dashboard = useDemoDashboard
@@ -77,6 +78,7 @@ function parseRouteSelection(tab: NewsDeskTab, selectionPath: string[] | null |
77
78
  if (!segments.length) return {};
78
79
  if (segments[0] === "forum" && segments[1]) return { forumThread: segments[1] };
79
80
  if (tab === "administration") return { panel: segments[0] ?? null };
81
+ if (tab === "desks") return { category: segments[0] ?? null };
80
82
  if (tab === "topics") return { category: segments[1] ?? segments[0] ?? null };
81
83
  if (tab === "references") return { reference: segments[0] ?? null };
82
84
  if (tab === "concepts") return { category: segments[0] ?? null };
@@ -101,16 +103,3 @@ function getFirstSearchParam(searchParams: unknown, key: string): string | null
101
103
  const first = Array.isArray(value) ? value[0] : value;
102
104
  return typeof first === "string" && first.trim() ? first.trim() : null;
103
105
  }
104
-
105
- function parseNewsDeskTab(sectionValue: string | string[] | null | undefined, legacyTabValue: string | string[] | null | undefined): NewsDeskTab {
106
- const section = Array.isArray(sectionValue) ? sectionValue[0] : sectionValue;
107
- const legacyTab = Array.isArray(legacyTabValue) ? legacyTabValue[0] : legacyTabValue;
108
- const rawValue = section ?? (legacyTab === "categories" ? "topics" : legacyTab);
109
- const value = rawValue === "users" || rawValue === "doctrine"
110
- ? "administration"
111
- : rawValue === "desks"
112
- ? "topics"
113
- : rawValue;
114
- if (value === "administration" || value === "topics" || value === "concepts" || value === "references" || value === "insights" || value === "messages" || value === "assignments" || value === "search") return value;
115
- return "overview";
116
- }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { generateClient } from "aws-amplify/data";
4
+ import { testEditorOverrideAllowed } from "../lib/test-editor-override";
4
5
  import { downloadData, getUrl } from "aws-amplify/storage";
5
6
  import type { Schema } from "../amplify/data/resource";
6
7
  import type { NewsDeskAppendix, NewsDeskCategoryTreeNode } from "../lib/content-types";
@@ -38,6 +39,7 @@ import type {
38
39
  UserDirectoryEntry,
39
40
  } from "../lib/category-repository";
40
41
  import { relationTypeKey } from "../lib/semantic-graph";
42
+ import { CONTENT_ITEM_STAGES, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
41
43
  import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
42
44
  import { configureAmplifyClient } from "./amplify-client-provider";
43
45
  import { isUnauthenticatedError, loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
@@ -252,7 +254,7 @@ const UPDATE_MESSAGE_MUTATION = `
252
254
  const NEWSROOM_ASSIGNMENT_FEED_QUERY = `
253
255
  query ListAssignmentsByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelAssignmentFilterInput) {
254
256
  listAssignmentsByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
255
- items { id assignmentTypeKey queueKey queueStatusKey status priority title summary assigneeType assigneeId assigneeKey claimedAt claimExpiresAt completedAt canceledAt corpusId categorySetId classifierId sectionId sectionKey sectionType sectionStatusKey sectionQueueStatusKey primaryFocusCategoryKey topicScopeCategoryKeys sourceSnapshotId importRunId createdBy createdAt updatedAt newsroomFeedKey }
257
+ items { id assignmentTypeKey queueKey queueStatusKey status priority title summary assigneeType assigneeId assigneeKey claimedAt claimExpiresAt completedAt canceledAt corpusId categorySetId classifierId sectionId sectionKey sectionType sectionStatusKey sectionQueueStatusKey primaryFocusCategoryKey topicScopeCategoryKeys sourceSnapshotId importRunId createdBy createdAt updatedAt newsroomFeedKey itemLineageId }
256
258
  nextToken
257
259
  }
258
260
  }
@@ -2156,6 +2158,41 @@ export async function loadNewsroomAssignmentPage(options: NewsroomAssignmentPage
2156
2158
  });
2157
2159
  }
2158
2160
 
2161
+ const CONTENT_ITEMS_BY_STAGE_QUERY = `
2162
+ query ListItemsByStageAndUpdatedAt($stage: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
2163
+ listItemsByStageAndUpdatedAt(stage: $stage, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
2164
+ items { id lineageId versionNumber stage type status title headline slug section updatedAt }
2165
+ nextToken
2166
+ }
2167
+ }
2168
+ `;
2169
+
2170
+ export async function loadNewsroomContentItemVersions(): Promise<ContentItemVersionRecord[]> {
2171
+ configureAmplifyClient();
2172
+ const client = generateClient<Schema>() as unknown as {
2173
+ graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ContentItemVersionRecord>>;
2174
+ };
2175
+ const field = "listItemsByStageAndUpdatedAt";
2176
+ const versionsPerStage = await Promise.all(CONTENT_ITEM_STAGES.map(async (stage) => {
2177
+ const versions: ContentItemVersionRecord[] = [];
2178
+ let nextToken: string | null = null;
2179
+ do {
2180
+ const response: GraphQLConnectionResponse<ContentItemVersionRecord> = await client.graphql({
2181
+ query: CONTENT_ITEMS_BY_STAGE_QUERY,
2182
+ variables: { stage, sortDirection: "DESC", limit: NEWSROOM_PAGE_LIMIT, nextToken },
2183
+ authMode: USER_POOL_AUTH_MODE,
2184
+ });
2185
+ assertNoGraphQLErrors(response.errors);
2186
+ const connection = response.data?.[field];
2187
+ if (!connection) throw new Error(`Missing GraphQL connection payload for ${field}.`);
2188
+ versions.push(...((connection.items ?? []).filter(Boolean) as ContentItemVersionRecord[]));
2189
+ nextToken = connection.nextToken ?? null;
2190
+ } while (nextToken);
2191
+ return versions;
2192
+ }));
2193
+ return versionsPerStage.flat();
2194
+ }
2195
+
2159
2196
  export async function loadModelPayloadsForOwner(
2160
2197
  ownerKind: string,
2161
2198
  ownerId: string,
@@ -2569,8 +2606,7 @@ export async function runNewsroomKnowledgeQuery(input: Record<string, unknown>):
2569
2606
 
2570
2607
  export function hasTestEditorOverride(): boolean {
2571
2608
  if (typeof window === "undefined") return false;
2572
- const allowOverride = process.env.NODE_ENV === "test"
2573
- || process.env.NEXT_PUBLIC_ENABLE_TEST_EDITOR_OVERRIDE === "true";
2609
+ const allowOverride = testEditorOverrideAllowed(process.env.NODE_ENV, process.env.NEXT_PUBLIC_ENABLE_TEST_EDITOR_OVERRIDE);
2574
2610
  if (!allowOverride) return false;
2575
2611
  return window.localStorage.getItem(TEST_EDITOR_STORAGE_KEY) === "true";
2576
2612
  }
@@ -3682,3 +3718,54 @@ function normalizeUnknownErrorMessage(error: unknown, fallback: string): string
3682
3718
  return fallback;
3683
3719
  }
3684
3720
  }
3721
+
3722
+ const CREATE_KNOWLEDGE_RAW_PAYLOAD_MUTATION = `
3723
+ mutation CreateKnowledgeRawPayload($input: CreateKnowledgeRawPayloadInput!) {
3724
+ createKnowledgeRawPayload(input: $input) { id }
3725
+ }
3726
+ `;
3727
+
3728
+ const GET_KNOWLEDGE_RAW_PAYLOAD_QUERY = `
3729
+ query GetKnowledgeRawPayload($id: ID!) {
3730
+ getKnowledgeRawPayload(id: $id) { id }
3731
+ }
3732
+ `;
3733
+
3734
+ /** The relevance cyclotron's latest cyclotron-status/v1 snapshot, as the sweep recorded it. */
3735
+ export async function loadCyclotronStatus(cyclotronId: string): Promise<import("cyclotron/cyclotron-status").CyclotronStatus | null> {
3736
+ const { cyclotronStatusPayloadId, latestCyclotronStatus } = await import("../lib/relevance-status");
3737
+ const payloads = await loadModelPayloadsForOwner("knowledgeRawPayload", cyclotronStatusPayloadId(cyclotronId), ["raw_payload"]);
3738
+ return latestCyclotronStatus(payloads.map((payload) => ({ json: payload.json, updatedAt: payload.attachment.updatedAt })));
3739
+ }
3740
+
3741
+ /** Ask the next decide-relevance sweep to set or clear a manual review rate. */
3742
+ export async function requestCyclotronReviewRate(
3743
+ cyclotronId: string,
3744
+ request: import("../lib/relevance-status").ReviewRateRequest,
3745
+ ): Promise<void> {
3746
+ const { reviewRateRequestPayloadId, safeId } = await import("../lib/relevance-status");
3747
+ const id = reviewRateRequestPayloadId(cyclotronId);
3748
+ const existing = await runGraphql<{ getKnowledgeRawPayload?: { id: string } | null }>(GET_KNOWLEDGE_RAW_PAYLOAD_QUERY, { id });
3749
+ if (!existing.getKnowledgeRawPayload) {
3750
+ await runGraphql(CREATE_KNOWLEDGE_RAW_PAYLOAD_MUTATION, {
3751
+ input: {
3752
+ id,
3753
+ ownerType: "cyclotron",
3754
+ ownerId: safeId(cyclotronId),
3755
+ payloadKind: "cyclotron-review-rate-request",
3756
+ createdAt: request.requestedAt,
3757
+ updatedAt: request.requestedAt,
3758
+ },
3759
+ });
3760
+ }
3761
+ await uploadModelPayloadForOwner({
3762
+ ownerKind: "knowledgeRawPayload",
3763
+ ownerId: id,
3764
+ ownerLineageId: id,
3765
+ role: "raw_payload",
3766
+ sortKey: `review-rate-request-${request.requestedAt}`,
3767
+ filename: "review-rate-request.json",
3768
+ mediaType: "application/json",
3769
+ content: JSON.stringify(request),
3770
+ });
3771
+ }
@@ -12,6 +12,8 @@ import { ARCHIVE_PREVIEW_HEIGHT, ARCHIVE_PREVIEW_WIDTH } from "../lib/archive-ty
12
12
  import type { EditionContent, NewsDeskAppendix, NewsDeskCategoryTreeNode } from "../lib/content-types";
13
13
  import { shouldBypassImageOptimization } from "../lib/image-url";
14
14
  import type { PublicationItem } from "../lib/publication-items";
15
+ import { clearPretextMeasurementCache } from "../lib/pretext-layout";
16
+ import { toPublicReaderPath } from "../lib/reader-base-path";
15
17
  import { SITE_BRAND } from "../lib/site-brand";
16
18
  import { resolveThemedImageSrc, type ResolvedTheme } from "../lib/themed-image";
17
19
  import { loadEditorCategoryTreeState } from "./news-desk-taxonomy-client";
@@ -130,7 +132,9 @@ export function Newspaper({
130
132
  if (!fonts) return;
131
133
  let cancelled = false;
132
134
  const refreshAfterFontLoad = () => {
133
- if (!cancelled) setFontRevision((revision) => revision + 1);
135
+ if (cancelled) return;
136
+ clearPretextMeasurementCache();
137
+ setFontRevision((revision) => revision + 1);
134
138
  };
135
139
  fonts.ready.then(refreshAfterFontLoad).catch(() => undefined);
136
140
  fonts.addEventListener("loadingdone", refreshAfterFontLoad);
@@ -1877,7 +1881,7 @@ function getPageHref(pageNumber: number, editionBasePath?: string): string {
1877
1881
  }
1878
1882
 
1879
1883
  function getArticleHref(articleSlug: string, editionBasePath?: string): string {
1880
- return editionBasePath ? `${normalizePath(editionBasePath)}/${encodeURIComponent(articleSlug)}` : `/articles/${articleSlug}`;
1884
+ return editionBasePath ? `${normalizePath(editionBasePath)}/${encodeURIComponent(articleSlug)}` : toPublicReaderPath(`/articles/${articleSlug}`);
1881
1885
  }
1882
1886
 
1883
1887
  function getItemAnchorHref(articleSlug: string, pageNumber: number, editionBasePath?: string): string {