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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/amplify/auth/redirect-urls.js +54 -0
  2. package/amplify/auth/redirect-urls.ts +66 -0
  3. package/amplify/auth/resource.js +20 -5
  4. package/amplify/auth/resource.ts +27 -5
  5. package/amplify/data/resource.js +28 -70
  6. package/amplify/data/resource.ts +29 -70
  7. package/amplify/data/schema.js +33 -58
  8. package/amplify/data/schema.ts +34 -58
  9. package/amplify/functions/category-action/handler.ts +5 -3
  10. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  11. package/amplify/functions/console-chat-responder/resource.js +0 -7
  12. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  13. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  14. package/amplify/functions/content-actions/handler.py +45 -3
  15. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  16. package/amplify/inbound-email/ses-intake.js +86 -0
  17. package/amplify/inbound-email/ses-intake.ts +110 -0
  18. package/amplify/rebuild-trigger-settings.js +22 -0
  19. package/amplify/rebuild-trigger-settings.ts +42 -0
  20. package/amplify/resolver-waves.js +26 -0
  21. package/amplify/resolver-waves.ts +34 -0
  22. package/amplify/site-backend-config.ts +36 -1
  23. package/amplify/site-backend-names.js +103 -0
  24. package/amplify/site-backend-names.ts +166 -0
  25. package/amplify/site-backend.js +93 -170
  26. package/amplify/site-backend.ts +126 -196
  27. package/amplify/storage/resource.js +3 -0
  28. package/amplify/storage/resource.ts +3 -0
  29. package/amplify/storage-backups/construct.js +31 -0
  30. package/amplify/storage-backups/construct.ts +47 -0
  31. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  32. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
  33. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  34. package/app/api/media/[...path]/route.ts +5 -16
  35. package/app/api/revalidate/route.ts +8 -7
  36. package/app/articles/[slug]/page.tsx +6 -4
  37. package/app/blog-rhythm.css +278 -0
  38. package/app/globals.css +21 -0
  39. package/app/layout.tsx +16 -0
  40. package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
  41. package/app/newsroom/[section]/page.tsx +4 -3
  42. package/app/newsroom/articles/[id]/page.tsx +15 -0
  43. package/app/newsroom/articles/page.tsx +13 -0
  44. package/app/newsroom/layout.tsx +4 -9
  45. package/app/page.tsx +18 -5
  46. package/app/robots.ts +2 -5
  47. package/bin/papyrus-app.mjs +12 -2
  48. package/bin/papyrus-infra.mjs +29 -3
  49. package/components/amplify-client-provider.tsx +4 -22
  50. package/components/archive-shell.tsx +2 -22
  51. package/components/article-video.tsx +247 -3
  52. package/components/blog-page-background.tsx +23 -6
  53. package/components/markus-ir-preview.tsx +300 -0
  54. package/components/news-desk-taxonomy-client.ts +51 -0
  55. package/components/newspaper.tsx +6 -2
  56. package/components/newsroom-access-gate.tsx +67 -0
  57. package/components/newsroom-article-editor.tsx +741 -0
  58. package/components/newsroom-articles-view.tsx +129 -0
  59. package/components/newsroom-client-shell.tsx +12 -0
  60. package/components/newsroom-ops-overview.tsx +28 -11
  61. package/components/newsroom-ops-shell.tsx +3 -1
  62. package/components/newsroom-references-view.tsx +83 -8
  63. package/components/papyrus-console-shell.tsx +4 -2
  64. package/components/pictogram-figure.tsx +9 -2
  65. package/components/presentation-header.tsx +135 -0
  66. package/components/presentation-rhythm-hrule.tsx +17 -0
  67. package/components/reference-cyclotron-status.tsx +109 -0
  68. package/components/reference-relevance-review.tsx +75 -0
  69. package/components/settings-page.tsx +2 -1
  70. package/components/topic-steering-workspace.tsx +54 -64
  71. package/components/use-blog-header-obstacles.ts +81 -0
  72. package/components/use-brand-fonts-ready.ts +28 -0
  73. package/components/use-rhythm-overlay.ts +29 -0
  74. package/infra/amplify-app-shell.js +228 -50
  75. package/infra/amplify-app-shell.ts +256 -53
  76. package/infra/build-specs.js +15 -5
  77. package/infra/build-specs.ts +18 -7
  78. package/infra/github-oidc-provider.js +24 -0
  79. package/infra/github-oidc-provider.ts +30 -0
  80. package/infra/index.js +1 -0
  81. package/infra/site-config.js +236 -11
  82. package/infra/site-config.ts +276 -15
  83. package/lib/amplify-outputs-path.ts +5 -1
  84. package/lib/blog-feature-solver.ts +459 -0
  85. package/lib/blog-rhythm.ts +134 -0
  86. package/lib/brand-fonts-watch.ts +43 -0
  87. package/lib/brand-fonts.ts +112 -0
  88. package/lib/cached-content-repository.ts +12 -0
  89. package/lib/category-repository.ts +42 -93
  90. package/lib/content-actions-demo.ts +190 -0
  91. package/lib/content-repository.ts +3 -0
  92. package/lib/content-source-context.ts +12 -2
  93. package/lib/content-types.ts +4 -0
  94. package/lib/edition-routes.ts +31 -29
  95. package/lib/graphql-content-repository.ts +61 -9
  96. package/lib/media-route-response.ts +45 -0
  97. package/lib/newspaper-layout.ts +3 -2
  98. package/lib/newsroom-articles.ts +325 -0
  99. package/lib/newsroom-base-path.ts +12 -3
  100. package/lib/newsroom-category-drilldown.ts +3 -2
  101. package/lib/newsroom-demo-articles.ts +90 -0
  102. package/lib/newsroom-forum-routes.ts +6 -4
  103. package/lib/newsroom-index-filters.ts +14 -10
  104. package/lib/newsroom-media-backend.ts +142 -0
  105. package/lib/newsroom-media.ts +182 -0
  106. package/lib/newsroom-nav.ts +3 -0
  107. package/lib/oauth-redirect-priority.ts +40 -0
  108. package/lib/papyrus-web-locations.ts +7 -6
  109. package/lib/presentation-footer.ts +3 -2
  110. package/lib/pretext-layout.ts +12 -3
  111. package/lib/reader-base-path.ts +24 -0
  112. package/lib/reader-static-params.ts +13 -0
  113. package/lib/reference-policy.ts +5 -0
  114. package/lib/relevance-decisions.ts +103 -0
  115. package/lib/relevance-status.ts +63 -0
  116. package/lib/renderer.ts +7 -0
  117. package/lib/revalidate-secret.ts +59 -0
  118. package/lib/robots-policy.ts +14 -0
  119. package/lib/root-route-redirect.ts +16 -0
  120. package/lib/semantic-graph.ts +7 -0
  121. package/lib/site-brand.ts +51 -0
  122. package/lib/staging-gated-path.ts +23 -0
  123. package/lib/staging-preview-object.ts +114 -0
  124. package/lib/video-mode.ts +42 -0
  125. package/lib/video-script.ts +77 -0
  126. package/middleware.ts +23 -18
  127. package/package.json +45 -1
  128. package/renderers/pretext/article-page.tsx +39 -3
  129. package/renderers/pretext/presentation-shared.tsx +113 -0
  130. package/renderers/pretext/presentation-shell.tsx +15 -102
  131. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  132. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  133. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  134. package/routes.manifest.json +45 -2
  135. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  136. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  137. package/scripts/videoml/bundle-common.mjs +129 -0
  138. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  139. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  140. package/scripts/videoml/preview/preview-timing.ts +42 -0
  141. package/scripts/videoml/preview.html +77 -0
  142. package/scripts/videoml/standard-entry.tsx +46 -0
  143. package/src/sync.mjs +30 -6
  144. package/src/with-papyrus.mjs +19 -2
  145. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  146. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  147. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -0,0 +1,103 @@
1
+ import type { SemanticRelationRecord } from "./category-repository";
2
+
3
+ export const RELEVANCE_RELATION = "relevance_decision_is";
4
+
5
+ /** The relevance cyclotron's current decision about one reference, as the References tab shows it. */
6
+ export type RelevanceDecisionView = {
7
+ decisionRelationId: string;
8
+ decisionId: string;
9
+ label: string;
10
+ confidence: number | null;
11
+ version: number | null;
12
+ /** True when the cyclotron sent this decision to review. */
13
+ reviewRecommended: boolean;
14
+ reviewReason: "program" | "audit" | null;
15
+ reviewDetail: string | null;
16
+ classes: string[];
17
+ positiveLabel: string | null;
18
+ question: string | null;
19
+ /** Thumbs for a two-class decision with a positive label; per-class buttons otherwise. */
20
+ reviewControl: "thumbs" | "labels";
21
+ cyclotronId: string | null;
22
+ };
23
+
24
+ /** What an editor submits from the review control. */
25
+ export type RelevanceReview = {
26
+ label: string | null;
27
+ reasonCode: string | null;
28
+ explanation: string | null;
29
+ shareable: boolean;
30
+ };
31
+
32
+ /** The existing curation action an editor's review becomes. */
33
+ export type RelevanceCuration = {
34
+ action: "accept" | "reject";
35
+ reasonCode: string | null;
36
+ note: string | null;
37
+ decisionRelationId: string;
38
+ shareable: boolean;
39
+ };
40
+
41
+ function parseMetadata(value: unknown): Record<string, unknown> {
42
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
43
+ if (typeof value !== "string") return {};
44
+ try {
45
+ const parsed = JSON.parse(value);
46
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Current decisions keyed by reference lineage, from relations the Newsroom
54
+ * already loads: no extra request per row.
55
+ */
56
+ export function currentRelevanceDecisions(relations: SemanticRelationRecord[]): Map<string, RelevanceDecisionView> {
57
+ const decisions = new Map<string, RelevanceDecisionView>();
58
+ for (const relation of relations) {
59
+ if (relation.predicate !== RELEVANCE_RELATION || relation.relationState !== "current") continue;
60
+ const metadata = parseMetadata(relation.metadata);
61
+ const probabilities = parseMetadata(metadata.probabilities);
62
+ const reason = metadata.reviewReason === "program" || metadata.reviewReason === "audit" ? metadata.reviewReason : null;
63
+ const version = Number(relation.modelVersion ?? metadata.version);
64
+ decisions.set(relation.subjectLineageId, {
65
+ decisionRelationId: relation.id,
66
+ decisionId: String(metadata.decisionId ?? ""),
67
+ label: String(metadata.label ?? ""),
68
+ confidence: typeof relation.confidence === "number" ? relation.confidence : null,
69
+ version: Number.isFinite(version) ? version : null,
70
+ reviewRecommended: Boolean(relation.reviewRecommended),
71
+ reviewReason: reason,
72
+ reviewDetail: typeof metadata.reviewDetail === "string" ? metadata.reviewDetail : null,
73
+ classes: Array.isArray(metadata.labels) && metadata.labels.length
74
+ ? metadata.labels.map(String)
75
+ : Object.keys(probabilities),
76
+ positiveLabel: typeof metadata.positiveLabel === "string" ? metadata.positiveLabel : null,
77
+ question: typeof metadata.question === "string" ? metadata.question : null,
78
+ reviewControl: metadata.reviewControl === "thumbs" ? "thumbs" : "labels",
79
+ cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
80
+ });
81
+ }
82
+ return decisions;
83
+ }
84
+
85
+ /**
86
+ * Map a review to the existing curation mutation. The positive label accepts;
87
+ * any other label, or a review without a label, rejects with the reason code,
88
+ * which the existing scope-training rule turns back into a cyclotron label
89
+ * (out_of_scope and policy_exclusion) or none.
90
+ */
91
+ export function curationForRelevanceReview(decision: RelevanceDecisionView, review: RelevanceReview): RelevanceCuration {
92
+ const accepted = review.label !== null && review.label === decision.positiveLabel;
93
+ if (!accepted && !review.reasonCode) {
94
+ throw new Error("A review that does not accept the reference needs a reason code.");
95
+ }
96
+ return {
97
+ action: accepted ? "accept" : "reject",
98
+ reasonCode: accepted ? null : review.reasonCode,
99
+ note: review.explanation?.trim() || null,
100
+ decisionRelationId: decision.decisionRelationId,
101
+ shareable: review.shareable,
102
+ };
103
+ }
@@ -0,0 +1,63 @@
1
+ import type { CyclotronStatus } from "cyclotron/cyclotron-status";
2
+ import { isCyclotronStatus } from "cyclotron/cyclotron-status";
3
+
4
+ /** The KnowledgeRawPayload the decide-relevance sweep writes the status snapshot to. */
5
+ export function cyclotronStatusPayloadId(cyclotronId: string): string {
6
+ return `knowledge-raw-payload-cyclotron-status-${safeId(cyclotronId)}`;
7
+ }
8
+
9
+ /** The KnowledgeRawPayload an editor writes to ask for a manual review rate. */
10
+ export function reviewRateRequestPayloadId(cyclotronId: string): string {
11
+ return `knowledge-raw-payload-cyclotron-review-rate-request-${safeId(cyclotronId)}`;
12
+ }
13
+
14
+ /** Mirrors Python's ids.safe_id so both sides name the same record. */
15
+ export function safeId(value: string): string {
16
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 140);
17
+ }
18
+
19
+ export type ReviewRateRequest =
20
+ | { rate: number; expiresAt: string; setBy: string; requestedAt: string }
21
+ | { clear: true; setBy: string; requestedAt: string };
22
+
23
+ export function buildReviewRateRequest(
24
+ choice: { rate: number; days: number } | { clear: true },
25
+ setBy: string,
26
+ now: Date = new Date(),
27
+ ): ReviewRateRequest {
28
+ const requestedAt = now.toISOString();
29
+ if ("clear" in choice) return { clear: true, setBy, requestedAt };
30
+ if (!(choice.rate >= 0 && choice.rate <= 1)) throw new Error("A review rate is between 0% and 100%.");
31
+ if (!(choice.days >= 1)) throw new Error("A manual rate lasts at least one day.");
32
+ const expiresAt = new Date(now.getTime() + choice.days * 24 * 60 * 60 * 1000).toISOString();
33
+ return { rate: choice.rate, expiresAt, setBy, requestedAt };
34
+ }
35
+
36
+ /** The newest valid snapshot among a payload's attachments. */
37
+ export function latestCyclotronStatus(payloads: Array<{ json: unknown; updatedAt?: string | null }>): CyclotronStatus | null {
38
+ const valid = payloads
39
+ .filter((payload) => isCyclotronStatus(payload.json))
40
+ .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
41
+ return valid.length ? (valid[0].json as CyclotronStatus) : null;
42
+ }
43
+
44
+ /** What demo mode shows: an onboarding cyclotron that has made one decision. */
45
+ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
46
+ schema: "cyclotron-status/v1",
47
+ cyclotron: { id: "papyrus-relevance", classifier: "relevant", version: 1, refits: 0, fingerprint: "demo" },
48
+ asOf: "2026-05-16T12:00:00.000Z",
49
+ alignment: {
50
+ window: 200, labels: 0, accuracy: null, precision: null, recall: null,
51
+ positiveLabel: "include", measuredOn: "reviews selected by the cyclotron",
52
+ },
53
+ calibration: { saysSure: null, isRight: null, gapPoints: null, curve: [] },
54
+ reviewRate: {
55
+ state: "onboarding", rate: 1, reason: "Onboarding: every decision is reviewed until the cyclotron earns a lower rate.",
56
+ auditFloor: 0.05, confidenceThreshold: 0.7, override: null,
57
+ nextCheckAfterDecisions: 99,
58
+ nextStep: "Steps down to 50% after 2 more windows of 100 decisions with accuracy on confident decisions of at least 85% (at least 20 reviewed) and a calibration gap under 5 points. Next check in 99 decisions.",
59
+ expectedReviewsPerWeek: 1,
60
+ },
61
+ lastChange: null,
62
+ pending: { decisionsAwaitingReview: 1, staleSince: null },
63
+ };
package/lib/renderer.ts CHANGED
@@ -4,6 +4,7 @@ import type { EditionContent } from "./content-types";
4
4
  import type { PretextLayout } from "./renderer-config";
5
5
  import type { PresentationFooterEntry } from "./presentation-footer";
6
6
  import type { PublicationItem } from "./publication-items";
7
+ import type { VideoScriptRef } from "./video-script";
7
8
 
8
9
  /**
9
10
  * Renderer is the swappable render pipeline that turns standard content
@@ -36,6 +37,8 @@ export type RenderEditionProps = {
36
37
  export type ArticlePageEditionFooter = {
37
38
  editionBasePath: string;
38
39
  entries: PresentationFooterEntry[];
40
+ /** Section links for the rhythm blog layout's page header. Ignored by the classic layout. */
41
+ sections?: Array<{ key: string; label: string }>;
39
42
  subtitle: string;
40
43
  title?: string;
41
44
  };
@@ -46,6 +49,8 @@ export type RenderArticleProps = {
46
49
  backLabel?: string;
47
50
  editionFooter?: ArticlePageEditionFooter;
48
51
  editionDate?: string;
52
+ /** VideoML script for the preview player. */
53
+ videoScript?: VideoScriptRef | null;
49
54
  };
50
55
 
51
56
  export type RenderItemProps = {
@@ -54,6 +59,8 @@ export type RenderItemProps = {
54
59
  backLabel?: string;
55
60
  editionFooter?: ArticlePageEditionFooter;
56
61
  editionDate?: string;
62
+ /** VideoML script for the preview player. */
63
+ videoScript?: VideoScriptRef | null;
57
64
  };
58
65
 
59
66
  /**
@@ -0,0 +1,59 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
3
+
4
+ export const REVALIDATE_SECRET_PARAMETER_ENV = "PAPYRUS_REVALIDATE_SECRET_PARAMETER";
5
+ export const REVALIDATE_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
6
+
7
+ export type RevalidateSecretReader = (parameterName: string) => Promise<string | null>;
8
+
9
+ type CachedSecret = { parameterName: string; value: string; expiresAt: number };
10
+
11
+ let cachedSecret: CachedSecret | null = null;
12
+ let defaultSsmClient: SSMClient | null = null;
13
+
14
+ export function resetRevalidateSecretCache(): void {
15
+ cachedSecret = null;
16
+ }
17
+
18
+ async function readSecretFromSsm(parameterName: string): Promise<string | null> {
19
+ defaultSsmClient ??= new SSMClient({});
20
+ const response = await defaultSsmClient.send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
21
+ const value = response.Parameter?.Value?.trim();
22
+ return value ? value : null;
23
+ }
24
+
25
+ export async function loadRevalidateSecret(
26
+ options: { environment?: Record<string, string | undefined>; readSecret?: RevalidateSecretReader; now?: number } = {},
27
+ ): Promise<string | null> {
28
+ const environment = options.environment ?? process.env;
29
+ const parameterName = environment[REVALIDATE_SECRET_PARAMETER_ENV]?.trim();
30
+ if (!parameterName) return null;
31
+ const now = options.now ?? Date.now();
32
+ if (cachedSecret && cachedSecret.parameterName === parameterName && cachedSecret.expiresAt > now) {
33
+ return cachedSecret.value;
34
+ }
35
+ try {
36
+ const value = (await (options.readSecret ?? readSecretFromSsm)(parameterName))?.trim();
37
+ if (!value) return null;
38
+ cachedSecret = { parameterName, value, expiresAt: now + REVALIDATE_SECRET_CACHE_MILLISECONDS };
39
+ return value;
40
+ } catch (error) {
41
+ console.error(`Revalidate secret parameter could not be read (${error instanceof Error ? error.name : "error"}).`);
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export function secretsMatch(provided: string | null | undefined, expected: string): boolean {
47
+ const providedBytes = Buffer.from((provided ?? "").trim());
48
+ const expectedBytes = Buffer.from(expected);
49
+ return providedBytes.length === expectedBytes.length && timingSafeEqual(providedBytes, expectedBytes);
50
+ }
51
+
52
+ export async function isAuthorizedRevalidateRequest(
53
+ providedSecret: string | null | undefined,
54
+ options: Parameters<typeof loadRevalidateSecret>[0] = {},
55
+ ): Promise<boolean> {
56
+ const expected = await loadRevalidateSecret(options);
57
+ if (!expected) return false;
58
+ return secretsMatch(providedSecret, expected);
59
+ }
@@ -0,0 +1,14 @@
1
+ import type { MetadataRoute } from "next";
2
+ import { getRootRoute, getSiteBrand } from "./site-brand";
3
+ import { isIndexable } from "./site-env";
4
+
5
+ const DISALLOW_ALL: MetadataRoute.Robots = { rules: { userAgent: "*", disallow: "/" } };
6
+ const ALLOW_ALL: MetadataRoute.Robots = { rules: { userAgent: "*", allow: "/" } };
7
+
8
+ export function buildRobots(
9
+ environment: Record<string, string | undefined> = process.env,
10
+ brand = getSiteBrand(),
11
+ ): MetadataRoute.Robots {
12
+ if (getRootRoute(brand).kind === "newsroom") return DISALLOW_ALL;
13
+ return isIndexable(environment) ? ALLOW_ALL : DISALLOW_ALL;
14
+ }
@@ -0,0 +1,16 @@
1
+ export type RootSearchParams = Record<string, string | string[] | undefined>;
2
+
3
+ export function buildRootRedirectTarget(destination: string, searchParams: RootSearchParams | undefined): string {
4
+ const query = new URLSearchParams();
5
+ for (const [name, value] of Object.entries(searchParams ?? {})) {
6
+ if (Array.isArray(value)) {
7
+ for (const entry of value) query.append(name, entry);
8
+ } else if (value !== undefined) {
9
+ query.append(name, value);
10
+ }
11
+ }
12
+ const serialized = query.toString();
13
+ if (!serialized) return destination;
14
+ const separator = destination.includes("?") ? "&" : "?";
15
+ return `${destination}${separator}${serialized}`;
16
+ }
@@ -1,3 +1,4 @@
1
+ import { toPublicNewsroomPath } from "./newsroom-base-path";
1
2
  import type {
2
3
  AssignmentRecord,
3
4
  CategorySteeringCategory,
@@ -31,6 +32,7 @@ export type SemanticObjectKind = typeof SEMANTIC_OBJECT_KINDS[number];
31
32
  export type SemanticPredicateId =
32
33
  | "classified_as"
33
34
  | "quality_rating_is"
35
+ | "relevance_decision_is"
34
36
  | "reference_summary_100_tokens"
35
37
  | "reference_summary_200_tokens"
36
38
  | "reference_summary_500_tokens"
@@ -79,6 +81,7 @@ export const SEMANTIC_PREDICATES: SemanticPredicateDefinition[] = [
79
81
  { id: "about", label: "about", group: "commentary", inverseLabel: "commentary" },
80
82
  { id: "comment", label: "comments on", group: "commentary", inverseLabel: "commented on by", contextPackTags: ["reference_curation", "editing", "research", "assignment_context"] },
81
83
  { id: "ingestion_rationale", label: "ingestion rationale for", group: "commentary", inverseLabel: "ingestion rationale", contextPackTags: ["reference_curation", "editing", "research", "assignment_context"] },
84
+ { id: "relevance_decision_is", label: "relevance decision is", group: "workflow", inverseLabel: "relevance decision for", contextPackTags: ["reference_curation"] },
82
85
  { id: "requests_work_on", label: "requests work on", group: "workflow", inverseLabel: "requested work", contextPackTags: ["assignment_context", "editing"] },
83
86
  { id: "uses_evidence", label: "uses evidence", group: "evidence", inverseLabel: "used as evidence by", contextPackTags: ["assignment_context", "research", "reference_graph"] },
84
87
  { id: "uses_signal", label: "uses signal", group: "evidence", inverseLabel: "signal for", contextPackTags: ["assignment_context", "research", "reference_graph"] },
@@ -175,6 +178,10 @@ export function predicateLabel(predicate: string, direction: "outgoing" | "incom
175
178
  }
176
179
 
177
180
  export function newsDeskHrefForSemanticObject(kind: string, lineageId: string): string {
181
+ return toPublicNewsroomPath(internalNewsDeskHrefForSemanticObject(kind, lineageId));
182
+ }
183
+
184
+ function internalNewsDeskHrefForSemanticObject(kind: string, lineageId: string): string {
178
185
  const encoded = encodeURIComponent(lineageId);
179
186
  if (kind === "reference") return `/newsroom/references/${encoded}`;
180
187
  if (kind === "category") return `/newsroom/topics?category=${encoded}`;
package/lib/site-brand.ts CHANGED
@@ -2,6 +2,7 @@ import type { ComponentType } from "react";
2
2
  import type { EditionPresentationFormat } from "./content-types";
3
3
  import type { Article, ArticleVideoAsset } from "./articles";
4
4
  import type { BlogPageBackgroundProps } from "../components/blog-page-background";
5
+ import type { SiteBrandFont } from "./brand-fonts";
5
6
  import type { PictogramFigureProps } from "../components/pictogram-figure";
6
7
  import papyrusSite from "papyrus-site";
7
8
  import {
@@ -24,6 +25,23 @@ export type SiteBrandComponents = {
24
25
  BlogPageBackground?: ComponentType<BlogPageBackgroundProps>;
25
26
  };
26
27
 
28
+ /**
29
+ * One line of a brand's header tagline: an emphasized lead and a plain tail,
30
+ * e.g. `{ emphasis: "Practical advice", tail: "for staying secure" }`.
31
+ */
32
+ export type SiteBrandTaglineLine = {
33
+ emphasis: string;
34
+ tail: string;
35
+ };
36
+
37
+ /**
38
+ * Blog presentation layout. `"classic"` (the default) is the original blog
39
+ * layout. `"rhythm"` snaps every height and gap to the vertical rhythm grid,
40
+ * floats featured images on the grid, draws rhythm rules between items, adds
41
+ * the eyebrow/tagline header and the rhythm overlay hotkey (Ctrl+=).
42
+ */
43
+ export type SiteBrandBlogLayout = "classic" | "rhythm";
44
+
27
45
  /** Default scenario content a brand supplies for the layout lab. */
28
46
  export type SiteBrandDemoEdition = {
29
47
  title: string;
@@ -34,6 +52,18 @@ export type SiteBrandDemoEdition = {
34
52
  editionVideo?: ArticleVideoAsset;
35
53
  };
36
54
 
55
+ /**
56
+ * Video slot: how a publication supplies its own VideoML scene components.
57
+ * Paths are relative to the publication root.
58
+ */
59
+ export type SiteBrandVideo = {
60
+ /** TSX module that registers the brand's `@videoml` scene components via `window.Babulus.registerComponent`. */
61
+ bundleEntry: string;
62
+ /** DSL element aliases for the browser preview, e.g. `{ "quote-card": "acme-quote-card" }`. */
63
+ sceneComponents?: Record<string, string>;
64
+ rhythm?: Record<string, unknown>;
65
+ };
66
+
37
67
  export type SiteBrand = {
38
68
  id: SiteBrandId;
39
69
  appTitle: string;
@@ -41,6 +71,10 @@ export type SiteBrand = {
41
71
  mastheadTitle: string;
42
72
  mastheadSubtitle: string;
43
73
  mastheadTagline?: string;
74
+ /** Rhythm blog layout only: small line above the masthead title, e.g. "Acme Research". The first word is emphasized. */
75
+ mastheadEyebrow?: string;
76
+ /** Rhythm blog layout only: structured tagline; `mastheadTagline` stays the plain-text fallback. */
77
+ mastheadTaglineLines?: SiteBrandTaglineLine[];
44
78
  backToHomeLabel: string;
45
79
  articleTitleSuffix: string;
46
80
  placeholderByline: string;
@@ -68,6 +102,8 @@ export type SiteBrand = {
68
102
  rootRoute?: RootRouteConfig;
69
103
  /** Public URL prefix for newsroom routes. Default `/newsroom`; use `""` on a CMS-only subdomain. */
70
104
  newsroomBasePath?: string;
105
+ /** Public URL prefix for the Pretext reader (home, editions, articles, archive, settings). Default `""`; e.g. `/information`. */
106
+ readerBasePath?: string;
71
107
  opsChrome: OpsChrome;
72
108
  corpusKey: string;
73
109
  steeringConfigPath: string;
@@ -75,7 +111,22 @@ export type SiteBrand = {
75
111
  analysisProfilesPath: string;
76
112
  publicationName: string;
77
113
  components?: SiteBrandComponents;
114
+ /**
115
+ * Video player chrome. `"native"` (default) is the browser's controls;
116
+ * `"framed"` wraps the media in `.article-video__media` and renders a
117
+ * "Play Video" button with a seek bar (`.article-video__cta*`), styled by the publication CSS.
118
+ */
119
+ videoPlayer?: "native" | "framed";
120
+ /** Blog presentation layout. Default `"classic"`. See `SiteBrandBlogLayout`. */
121
+ blogLayout?: SiteBrandBlogLayout;
122
+ /**
123
+ * Brand font slot: families the root layout loads (Google Fonts stylesheet
124
+ * or self-hosted files) and exposes as CSS custom properties. See
125
+ * `lib/brand-fonts.ts` and docs/brand-slots.md.
126
+ */
127
+ fonts?: SiteBrandFont[];
78
128
  demoEdition?: SiteBrandDemoEdition;
129
+ video?: SiteBrandVideo;
79
130
  };
80
131
 
81
132
  const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
@@ -0,0 +1,23 @@
1
+ import { normalizeReaderBasePath } from "./reader-base-path";
2
+
3
+ function isStaticOrApiPath(pathname: string): boolean {
4
+ return (
5
+ pathname.startsWith("/_next")
6
+ || pathname.startsWith("/api")
7
+ || pathname === "/favicon.ico"
8
+ || pathname.startsWith("/icon")
9
+ || /\.[a-zA-Z0-9]+$/.test(pathname)
10
+ );
11
+ }
12
+
13
+ export { isStaticOrApiPath };
14
+
15
+ export function isStagingGatedPath(pathname: string, readerBasePath: string = "", newsroomAtRoot: boolean = false): boolean {
16
+ if (newsroomAtRoot) return false;
17
+ if (isStaticOrApiPath(pathname)) return false;
18
+ if (pathname === "/robots.txt") return false;
19
+ if (pathname === "/newsroom" || pathname.startsWith("/newsroom/")) return false;
20
+ const base = normalizeReaderBasePath(readerBasePath);
21
+ if (base) return pathname === base || pathname.startsWith(`${base}/`);
22
+ return true;
23
+ }
@@ -0,0 +1,114 @@
1
+ import { cookies } from "next/headers";
2
+ import { getUrl } from "aws-amplify/storage/server";
3
+ import { getAmplifyServerRuntime } from "./amplify-server-runtime";
4
+ import { getSiteEnv } from "./site-env";
5
+
6
+ export const PREVIEW_ROUTE_PREFIX = "/__preview";
7
+ export const PREVIEW_KEY_PREFIX = "preview/";
8
+
9
+ const PRESIGN_SECONDS = 60;
10
+ const NOT_FOUND_ERROR_NAMES = ["NotFound", "NoSuchKey"];
11
+ const FORBIDDEN_ERROR_NAMES = ["Forbidden", "AccessDenied"];
12
+ const PREVIEW_EXCLUDED_PREFIXES = ["/newsroom", "/api", "/_next", PREVIEW_ROUTE_PREFIX];
13
+
14
+ export function isStaticPreviewEnabled(environment: Record<string, string | undefined> = process.env): boolean {
15
+ return getSiteEnv(environment) === "staging" && (environment.PAPYRUS_STAGING_PREVIEW ?? "").trim() === "static";
16
+ }
17
+
18
+ export function previewCandidateKeys(requestPath: string): string[] | null {
19
+ if (requestPath.includes("//")) return null;
20
+ let decoded: string;
21
+ try {
22
+ decoded = decodeURIComponent(requestPath);
23
+ } catch {
24
+ return null;
25
+ }
26
+ if (decoded.includes("//") || decoded.includes("\\") || decoded.includes("\0")) return null;
27
+ const segments = decoded.split("/").filter((segment) => segment !== "");
28
+ if (segments.some((segment) => segment === ".." || segment === ".")) return null;
29
+ if (segments.length === 0) return [`${PREVIEW_KEY_PREFIX}index.html`];
30
+ const joined = segments.join("/");
31
+ if (decoded.endsWith("/")) return [`${PREVIEW_KEY_PREFIX}${joined}/index.html`];
32
+ const base = `${PREVIEW_KEY_PREFIX}${joined}`;
33
+ return [base, `${base}.html`, `${base}/index.html`];
34
+ }
35
+
36
+ export function shouldRewriteToPreview(pathname: string, newsroomAtRoot: boolean = false): boolean {
37
+ if (pathname === "/favicon.ico" || pathname === "/robots.txt" || pathname.startsWith("/icon")) return false;
38
+ if (PREVIEW_EXCLUDED_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) return false;
39
+ return !newsroomAtRoot || pathname.endsWith(".html");
40
+ }
41
+
42
+ export function isPreviewGatedPath(pathname: string, newsroomAtRoot: boolean = false): boolean {
43
+ return shouldRewriteToPreview(pathname, newsroomAtRoot) || pathname === PREVIEW_ROUTE_PREFIX || pathname.startsWith(`${PREVIEW_ROUTE_PREFIX}/`);
44
+ }
45
+
46
+ export function previewRewritePathname(pathname: string): string {
47
+ return `${PREVIEW_ROUTE_PREFIX}${pathname}`;
48
+ }
49
+
50
+ export function previewRequestPath(pathname: string): string {
51
+ return pathname.slice(PREVIEW_ROUTE_PREFIX.length) || "/";
52
+ }
53
+
54
+ function isNotFoundError(error: unknown): boolean {
55
+ const name = (error as { name?: string } | null)?.name ?? "";
56
+ return NOT_FOUND_ERROR_NAMES.includes(name);
57
+ }
58
+
59
+ function isForbiddenError(error: unknown): boolean {
60
+ const candidate = error as { name?: string; $metadata?: { httpStatusCode?: number }; metadata?: { httpStatusCode?: number } } | null;
61
+ const status = candidate?.$metadata?.httpStatusCode ?? candidate?.metadata?.httpStatusCode;
62
+ return status === 403 || FORBIDDEN_ERROR_NAMES.includes(candidate?.name ?? "");
63
+ }
64
+
65
+ async function presignFirstExistingKey(candidateKeys: string[]): Promise<URL | null> {
66
+ const { runWithAmplifyServerContext } = getAmplifyServerRuntime();
67
+ return runWithAmplifyServerContext({
68
+ nextServerContext: { cookies },
69
+ operation: async (contextSpec) => {
70
+ for (const key of candidateKeys) {
71
+ try {
72
+ const { url } = await getUrl(contextSpec, {
73
+ path: key,
74
+ options: { validateObjectExistence: true, expiresIn: PRESIGN_SECONDS },
75
+ });
76
+ return url;
77
+ } catch (error) {
78
+ if (!isNotFoundError(error)) throw error;
79
+ }
80
+ }
81
+ return null;
82
+ },
83
+ });
84
+ }
85
+
86
+ function notFound(): Response {
87
+ return new Response("Not found", { status: 404, headers: { "Cache-Control": "private, no-store" } });
88
+ }
89
+
90
+ export async function streamPreviewObject(pathname: string): Promise<Response> {
91
+ if (!isStaticPreviewEnabled()) return notFound();
92
+ const candidateKeys = previewCandidateKeys(previewRequestPath(pathname));
93
+ if (!candidateKeys) return notFound();
94
+ let presigned: URL | null;
95
+ try {
96
+ presigned = await presignFirstExistingKey(candidateKeys);
97
+ } catch (error) {
98
+ if (isForbiddenError(error)) {
99
+ return new Response("Staging preview is limited to editors and admins.", { status: 403, headers: { "Cache-Control": "private, no-store" } });
100
+ }
101
+ throw error;
102
+ }
103
+ if (!presigned) return notFound();
104
+ const upstream = await fetch(presigned);
105
+ if (!upstream.ok || !upstream.body) return notFound();
106
+ return new Response(upstream.body, {
107
+ status: 200,
108
+ headers: {
109
+ "Content-Type": upstream.headers.get("content-type") ?? "application/octet-stream",
110
+ "Cache-Control": "private, no-store",
111
+ "X-Robots-Tag": "noindex, nofollow",
112
+ },
113
+ });
114
+ }
@@ -0,0 +1,42 @@
1
+ export type VideoMode = "preview" | "mp4";
2
+
3
+ function parseVideoMode(value: string | null | undefined): VideoMode | null {
4
+ if (value === "preview" || value === "mp4") return value;
5
+ return null;
6
+ }
7
+
8
+ /** Server-safe mode from env only. Production always returns `"mp4"`. */
9
+ export function resolveVideoModeFromEnv(): VideoMode {
10
+ if (process.env.NODE_ENV === "production") return "mp4";
11
+ return parseVideoMode(process.env.NEXT_PUBLIC_PAPYRUS_VIDEO_MODE) ?? "preview";
12
+ }
13
+
14
+ /** Client-only `?video=` override. Returns null when absent or in production. */
15
+ export function resolveVideoModeFromSearch(search: string): VideoMode | null {
16
+ if (process.env.NODE_ENV === "production") return null;
17
+ const query = search.startsWith("?") ? search.slice(1) : search;
18
+ return parseVideoMode(new URLSearchParams(query).get("video"));
19
+ }
20
+
21
+ /**
22
+ * Effective video mode for the reader.
23
+ * Production always uses `"mp4"`. In development, `?video=` overrides
24
+ * `NEXT_PUBLIC_PAPYRUS_VIDEO_MODE` (default `"preview"`).
25
+ */
26
+ export function resolveVideoMode(): VideoMode {
27
+ if (process.env.NODE_ENV === "production") return "mp4";
28
+ if (typeof window !== "undefined") {
29
+ const fromSearch = resolveVideoModeFromSearch(window.location.search);
30
+ if (fromSearch) return fromSearch;
31
+ }
32
+ return resolveVideoModeFromEnv();
33
+ }
34
+
35
+ /**
36
+ * Server-safe gate for fetching VideoML DSL scripts.
37
+ * Always true in development so `?video=preview` and `?video=mp4` can both
38
+ * work without restarting the server. Production never fetches scripts.
39
+ */
40
+ export function shouldFetchVideoScripts(): boolean {
41
+ return process.env.NODE_ENV === "development";
42
+ }