@anthusai/papyrus 1.3.0 → 1.4.0-next.10

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 (141) hide show
  1. package/amplify/data/resource.js +134 -3
  2. package/amplify/data/resource.ts +145 -3
  3. package/amplify/data/schema.js +131 -0
  4. package/amplify/data/schema.ts +142 -0
  5. package/amplify/functions/category-action/handler.ts +272 -20
  6. package/amplify/functions/console-chat-responder/src/main.rs +1 -1
  7. package/amplify/functions/content-actions/handler.py +23 -0
  8. package/amplify/functions/content-actions/requirements.txt +1 -1
  9. package/amplify/functions/content-actions/resource.js +1 -1
  10. package/amplify/functions/content-actions/resource.ts +1 -1
  11. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  12. package/amplify/functions/renditions/requirements.txt +1 -1
  13. package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
  14. package/amplify/functions/shared/curation-delegation.ts +115 -0
  15. package/amplify/functions/shared/python-bundle.js +3 -3
  16. package/amplify/functions/shared/python-bundle.ts +3 -3
  17. package/amplify/functions/shared/reference-curation-metadata.ts +51 -0
  18. package/amplify/functions/shared/slack-events.ts +2 -2
  19. package/amplify/publication/renditions-function.js +1 -1
  20. package/amplify/publication/renditions-function.ts +1 -1
  21. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
  22. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
  23. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
  24. package/app/[year]/[month]/[day]/page.tsx +3 -0
  25. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
  26. package/app/api/archive/editions/route.ts +2 -0
  27. package/app/api/auth/prospectus/accounts/route.ts +9 -0
  28. package/app/api/local-preview/derive/route.ts +28 -0
  29. package/app/api/local-preview/media/route.ts +25 -0
  30. package/app/archive/page.tsx +3 -0
  31. package/app/globals.css +28 -28
  32. package/app/layout.tsx +8 -5
  33. package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
  34. package/app/newsroom/[section]/page.tsx +8 -1
  35. package/app/newsroom/articles/page.tsx +10 -0
  36. package/app/newsroom/insights/[threadId]/page.tsx +5 -16
  37. package/app/newsroom/insights/page.tsx +4 -12
  38. package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
  39. package/app/page.tsx +30 -1
  40. package/components/administration-delegations-panel.tsx +156 -0
  41. package/components/curation-delegation-client.ts +104 -0
  42. package/components/item-stream-view.tsx +63 -0
  43. package/components/markus-ir-preview.tsx +47 -2
  44. package/components/news-desk-client-provider.tsx +17 -2
  45. package/components/news-desk-page.tsx +0 -1
  46. package/components/news-desk-taxonomy-client.ts +154 -240
  47. package/components/newspaper.tsx +4 -4
  48. package/components/newsroom-article-editor.tsx +138 -40
  49. package/components/newsroom-assignment-detail.tsx +79 -0
  50. package/components/newsroom-assignments-view.tsx +1 -1
  51. package/components/newsroom-board-view.tsx +153 -43
  52. package/components/newsroom-client-shell.tsx +8 -1
  53. package/components/newsroom-newsprint-board.css +5 -46
  54. package/components/newsroom-ops-overview.tsx +27 -5
  55. package/components/newsroom-organizer-shell.tsx +36 -13
  56. package/components/newsroom-organizer.css +269 -13
  57. package/components/newsroom-references-view.tsx +118 -23
  58. package/components/newsroom-topics-view.tsx +1 -1
  59. package/components/priming-proof-page.tsx +17 -8
  60. package/components/prospectus-account-chooser.tsx +131 -0
  61. package/components/reference-cyclotron-priming.tsx +85 -0
  62. package/components/reference-cyclotron-status.tsx +5 -5
  63. package/components/reference-cyclotron-suggestion.tsx +143 -0
  64. package/components/reference-live-agreement.tsx +62 -0
  65. package/components/reference-section-suggestion.tsx +104 -0
  66. package/components/story-reporting-trail-client.ts +91 -0
  67. package/components/story-reporting-trail.tsx +115 -0
  68. package/components/topic-steering-workspace.tsx +280 -679
  69. package/components/ui/sheet.tsx +10 -4
  70. package/components/use-paged-content-item-versions.ts +151 -0
  71. package/components/wiki-concepts-view.tsx +153 -0
  72. package/components/wiki-index-client.ts +51 -0
  73. package/infra/amplify-app-shell.js +17 -3
  74. package/infra/amplify-app-shell.ts +18 -3
  75. package/infra/site-config.js +6 -1
  76. package/infra/site-config.ts +7 -1
  77. package/lib/approved-priming-proof-reports.ts +6 -3
  78. package/lib/approved-priming-proofs/papyrus.json +90 -0
  79. package/lib/article-flow.ts +95 -0
  80. package/lib/article-frame-solver.ts +519 -0
  81. package/lib/blog-feature-solver.ts +1 -109
  82. package/lib/category-repository.ts +1 -1
  83. package/lib/chrome-text.ts +504 -0
  84. package/lib/content-actions-client.ts +3 -0
  85. package/lib/content-actions-demo.ts +55 -0
  86. package/lib/content-item-lifecycle.ts +34 -5
  87. package/lib/content-source-context.ts +9 -1
  88. package/lib/curation-delegation-helpers.ts +69 -0
  89. package/lib/cyclotron-suggestions.ts +263 -0
  90. package/lib/front-matter-fields.ts +42 -0
  91. package/lib/front-mosaic.ts +1133 -0
  92. package/lib/furniture.ts +385 -0
  93. package/lib/graphql-content-repository.ts +42 -7
  94. package/lib/item-stream.ts +47 -0
  95. package/lib/labels.ts +37 -0
  96. package/lib/layout-config.ts +237 -0
  97. package/lib/layout-plan.ts +40 -11
  98. package/lib/layout-scenarios.ts +21 -11
  99. package/lib/layout-types.ts +457 -0
  100. package/lib/layout-warnings.ts +8 -0
  101. package/lib/local-preview-section-fixtures.ts +72 -0
  102. package/lib/local-preview-seed.ts +286 -0
  103. package/lib/local-preview-tenant.ts +27 -0
  104. package/lib/local-preview.ts +3 -0
  105. package/lib/newspaper-layout.ts +17 -3410
  106. package/lib/newsroom-articles.ts +2 -0
  107. package/lib/newsroom-assignments.ts +14 -1
  108. package/lib/newsroom-board.ts +99 -47
  109. package/lib/newsroom-demo-dashboard.ts +1 -1
  110. package/lib/newsroom-forum-routes.ts +2 -43
  111. package/lib/newsroom-index-filters.ts +3 -40
  112. package/lib/newsroom-media-backend.ts +5 -0
  113. package/lib/newsroom-nav.ts +1 -3
  114. package/lib/newsroom-organizer-nav.ts +9 -16
  115. package/lib/newsroom-section-routing.ts +1 -1
  116. package/lib/newsroom-sections.ts +12 -0
  117. package/lib/papyrus-web-locations.ts +3 -24
  118. package/lib/pretext-layout.ts +22 -0
  119. package/lib/priming-proof-report.ts +1 -0
  120. package/lib/prospectus-account-client.ts +90 -0
  121. package/lib/prospectus-sign-in-routes.ts +45 -0
  122. package/lib/prospectus-site-identity.ts +13 -1
  123. package/lib/prospectus-tenant.ts +22 -0
  124. package/lib/reader-static-params.ts +2 -1
  125. package/lib/relevance-decisions.ts +29 -0
  126. package/lib/relevance-status.ts +73 -0
  127. package/lib/reporting-story-budget.ts +2 -2
  128. package/lib/semantic-graph.ts +5 -5
  129. package/lib/site-brand.ts +28 -1
  130. package/lib/site-env.ts +5 -0
  131. package/lib/site-section-suggestions.ts +120 -0
  132. package/lib/site-stack.ts +1 -1
  133. package/lib/story-reporting-trail.ts +106 -0
  134. package/lib/tenant-keys.ts +32 -0
  135. package/lib/text-columns.ts +251 -0
  136. package/lib/wiki.ts +56 -0
  137. package/package.json +46 -3
  138. package/renderers/pretext/presentation-shell.tsx +1 -1
  139. package/renderers/pretext/rhythm-article-page.tsx +1 -18
  140. package/routes.manifest.json +34 -15
  141. package/app/newsroom/work/page.tsx +0 -10
@@ -11,9 +11,9 @@ type SheetSide = "bottom" | "left" | "right";
11
11
 
12
12
  const sideClasses: Record<SheetSide, string> = {
13
13
  bottom:
14
- "inset-x-0 bottom-0 top-auto max-h-[85dvh] w-full rounded-t-2xl border-t data-open:slide-in-from-bottom data-closed:slide-out-to-bottom",
15
- left: "inset-y-0 left-0 h-full w-[min(100vw,20rem)] rounded-r-2xl border-r data-open:slide-in-from-left data-closed:slide-out-to-left",
16
- right: "inset-y-0 right-0 h-full w-[min(100vw,20rem)] rounded-l-2xl border-l data-open:slide-in-from-right data-closed:slide-out-to-right",
14
+ "inset-x-0 bottom-0 top-auto max-h-[85dvh] w-full border-t data-open:slide-in-from-bottom data-closed:slide-out-to-bottom",
15
+ left: "inset-y-0 left-0 h-full w-[min(100vw,20rem)] border-r data-open:slide-in-from-left data-closed:slide-out-to-left",
16
+ right: "inset-y-0 right-0 h-full w-[min(100vw,20rem)] border-l data-open:slide-in-from-right data-closed:slide-out-to-right",
17
17
  };
18
18
 
19
19
  function Sheet({ ...props }: DialogPrimitive.Root.Props) {
@@ -31,17 +31,23 @@ function SheetClose({ ...props }: DialogPrimitive.Close.Props) {
31
31
  function SheetContent({
32
32
  children,
33
33
  className,
34
+ backdropClassName,
34
35
  showCloseButton = true,
35
36
  side = "bottom",
36
37
  ...props
37
38
  }: DialogPrimitive.Popup.Props & {
39
+ backdropClassName?: string;
38
40
  showCloseButton?: boolean;
39
41
  side?: SheetSide;
40
42
  }) {
41
43
  return (
42
44
  <DialogPrimitive.Portal>
43
45
  <DialogPrimitive.Backdrop
44
- className="fixed inset-0 z-50 bg-black/40 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0"
46
+ className={cn(
47
+ "fixed inset-0 z-50 bg-black/40 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
48
+ typeof className === "string" && className.includes("md:hidden") && "md:hidden",
49
+ backdropClassName,
50
+ )}
45
51
  />
46
52
  <DialogPrimitive.Popup
47
53
  className={cn(
@@ -0,0 +1,151 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import {
5
+ canonicalContentItemStage,
6
+ legacyStageKeysToLoad,
7
+ type ContentItemVersionRecord,
8
+ } from "../lib/content-item-lifecycle";
9
+ import { getContentItemStageDefinitions } from "../lib/site-brand";
10
+ import { loadNewsroomContentItemStagePage } from "./news-desk-taxonomy-client";
11
+
12
+ const REFRESH_INTERVAL_MILLISECONDS = 30_000;
13
+
14
+ type StageBucket = {
15
+ versions: ContentItemVersionRecord[];
16
+ nextToken: string | null;
17
+ pagesLoaded: number;
18
+ };
19
+
20
+ export type PagedContentItemVersions = {
21
+ versions: ContentItemVersionRecord[];
22
+ loading: boolean;
23
+ error: string | null;
24
+ stageHasMore: (canonicalStage: string) => boolean;
25
+ loadMoreForStage: (canonicalStage: string) => void;
26
+ };
27
+
28
+ function mergeFreshFirstPage(existing: StageBucket | undefined, fresh: StageBucket): StageBucket {
29
+ if (!existing || existing.pagesLoaded <= 1) return fresh;
30
+ const freshIds = new Set(fresh.versions.map(version => version.id));
31
+ return {
32
+ versions: [...fresh.versions, ...existing.versions.filter(version => !freshIds.has(version.id))],
33
+ nextToken: existing.nextToken,
34
+ pagesLoaded: existing.pagesLoaded,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Newest-updated content items per stage, one page per stage to start, more pages on demand.
40
+ * The periodic refresh re-fetches only first pages and keeps whatever later pages were already loaded.
41
+ */
42
+ export function usePagedContentItemVersions(options: {
43
+ enabled: boolean;
44
+ openedCollapsedUntilOpenedStages: readonly string[];
45
+ }): PagedContentItemVersions {
46
+ const { enabled, openedCollapsedUntilOpenedStages } = options;
47
+ const [buckets, setBuckets] = useState<Record<string, StageBucket>>({});
48
+ const [loading, setLoading] = useState(false);
49
+ const [error, setError] = useState<string | null>(null);
50
+ const bucketsRef = useRef(buckets);
51
+ const stageKeysBeingExtendedRef = useRef(new Set<string>());
52
+ const generationRef = useRef(0);
53
+
54
+ const openedStagesSignature = openedCollapsedUntilOpenedStages.join(",");
55
+ const stageKeysToLoad = useMemo(() => {
56
+ const stageDefinitions = getContentItemStageDefinitions();
57
+ return [
58
+ ...stageDefinitions
59
+ .filter(definition => !definition.collapsedUntilOpened || openedStagesSignature.split(",").includes(definition.key))
60
+ .map(definition => definition.key),
61
+ ...legacyStageKeysToLoad(stageDefinitions),
62
+ ];
63
+ }, [openedStagesSignature]);
64
+ const stageKeysToLoadRef = useRef(stageKeysToLoad);
65
+ stageKeysToLoadRef.current = stageKeysToLoad;
66
+
67
+ const storeBuckets = useCallback((update: (previous: Record<string, StageBucket>) => Record<string, StageBucket>) => {
68
+ const next = update(bucketsRef.current);
69
+ bucketsRef.current = next;
70
+ setBuckets(next);
71
+ }, []);
72
+
73
+ const refreshFirstPages = useCallback(async (request: number, isInitial: boolean, stageKeys: readonly string[]) => {
74
+ if (isInitial) setLoading(true);
75
+ try {
76
+ const freshBuckets = await Promise.all(stageKeys.map(async stageKey => {
77
+ const page = await loadNewsroomContentItemStagePage(stageKey);
78
+ return [stageKey, { versions: page.versions, nextToken: page.nextToken, pagesLoaded: 1 }] as const;
79
+ }));
80
+ if (generationRef.current !== request) return;
81
+ storeBuckets(previous => {
82
+ const next = { ...previous };
83
+ for (const [stageKey, fresh] of freshBuckets) next[stageKey] = mergeFreshFirstPage(previous[stageKey], fresh);
84
+ return next;
85
+ });
86
+ setError(null);
87
+ } catch (reason: unknown) {
88
+ if (generationRef.current === request && isInitial) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
89
+ } finally {
90
+ if (generationRef.current === request && isInitial) setLoading(false);
91
+ }
92
+ }, [storeBuckets]);
93
+
94
+ useEffect(() => {
95
+ const request = ++generationRef.current;
96
+ bucketsRef.current = {};
97
+ setBuckets({});
98
+ setError(null);
99
+ if (!enabled) { setLoading(false); return; }
100
+ void refreshFirstPages(request, true, stageKeysToLoadRef.current);
101
+ const refreshWhenVisible = () => {
102
+ if (document.visibilityState === "visible") void refreshFirstPages(request, false, stageKeysToLoadRef.current);
103
+ };
104
+ const interval = window.setInterval(refreshWhenVisible, REFRESH_INTERVAL_MILLISECONDS);
105
+ document.addEventListener("visibilitychange", refreshWhenVisible);
106
+ return () => {
107
+ generationRef.current = request + 1;
108
+ window.clearInterval(interval);
109
+ document.removeEventListener("visibilitychange", refreshWhenVisible);
110
+ };
111
+ }, [enabled, refreshFirstPages]);
112
+
113
+ useEffect(() => {
114
+ if (!enabled) return;
115
+ const stageKeysNotLoadedYet = stageKeysToLoad.filter(stageKey => !(stageKey in bucketsRef.current));
116
+ if (stageKeysNotLoadedYet.length) void refreshFirstPages(generationRef.current, false, stageKeysNotLoadedYet);
117
+ }, [enabled, refreshFirstPages, stageKeysToLoad]);
118
+
119
+ const stageKeysInCanonicalStage = useCallback((canonicalStage: string) => Object.keys(bucketsRef.current)
120
+ .filter(stageKey => canonicalContentItemStage(stageKey, getContentItemStageDefinitions()) === canonicalStage), []);
121
+
122
+ const loadMoreForStage = useCallback((canonicalStage: string) => {
123
+ const request = generationRef.current;
124
+ for (const stageKey of stageKeysInCanonicalStage(canonicalStage)) {
125
+ const bucket = bucketsRef.current[stageKey];
126
+ if (!bucket?.nextToken || stageKeysBeingExtendedRef.current.has(stageKey)) continue;
127
+ stageKeysBeingExtendedRef.current.add(stageKey);
128
+ loadNewsroomContentItemStagePage(stageKey, bucket.nextToken).then(page => {
129
+ if (generationRef.current !== request) return;
130
+ storeBuckets(previous => {
131
+ const current = previous[stageKey] ?? bucket;
132
+ const knownIds = new Set(current.versions.map(version => version.id));
133
+ return {
134
+ ...previous,
135
+ [stageKey]: {
136
+ versions: [...current.versions, ...page.versions.filter(version => !knownIds.has(version.id))],
137
+ nextToken: page.nextToken,
138
+ pagesLoaded: current.pagesLoaded + 1,
139
+ },
140
+ };
141
+ });
142
+ }).catch(() => undefined).finally(() => { stageKeysBeingExtendedRef.current.delete(stageKey); });
143
+ }
144
+ }, [stageKeysInCanonicalStage, storeBuckets]);
145
+
146
+ const stageHasMore = useCallback((canonicalStage: string) => stageKeysInCanonicalStage(canonicalStage)
147
+ .some(stageKey => Boolean(bucketsRef.current[stageKey]?.nextToken)), [stageKeysInCanonicalStage]);
148
+
149
+ const versions = useMemo(() => Object.values(buckets).flatMap(bucket => bucket.versions), [buckets]);
150
+ return { versions, loading, error, stageHasMore, loadMoreForStage };
151
+ }
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
5
+ import type { SemanticNodeRecord } from "../lib/category-repository";
6
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
7
+ import { getNewsroomNavHref } from "../lib/newsroom-nav";
8
+ import type { createSemanticGraphSnapshot } from "../lib/semantic-graph";
9
+ import {
10
+ conceptEditedAt, conceptId, conceptTitle, conceptWikiPath, conceptsByTitle, recentlyEditedConcepts, resolveConceptLink,
11
+ } from "../lib/wiki";
12
+ import { MarkusIrPreview } from "./markus-ir-preview";
13
+ import { createWikiIndexPage, loadWikiIndexPage, type WikiIndexPage } from "./wiki-index-client";
14
+
15
+ type SemanticGraph = ReturnType<typeof createSemanticGraphSnapshot>;
16
+
17
+ const editedFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
18
+
19
+ function formatEdited(value: string): string {
20
+ const parsed = new Date(value);
21
+ return value && !Number.isNaN(parsed.getTime()) ? editedFormat.format(parsed) : "";
22
+ }
23
+
24
+ function kindLabel(kind: string): string {
25
+ return kind.replace(/_/g, " ");
26
+ }
27
+
28
+ function ConceptLink({ children, demo, node, onOpen }: { children?: ReactNode; demo: boolean; node: SemanticNodeRecord; onOpen: (lineageId: string) => void }) {
29
+ return <a className="wiki-link" data-wiki-concept-link={conceptId(node)} href={getNewsroomNavHref(conceptWikiPath(node), demo)} onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey) return; event.preventDefault(); onOpen(conceptId(node)); }}>{children ?? conceptTitle(node)}</a>;
30
+ }
31
+
32
+ function ConceptRows({ demo, nodes, onOpen, showEdited }: { demo: boolean; nodes: SemanticNodeRecord[]; onOpen: (lineageId: string) => void; showEdited?: boolean }) {
33
+ if (!nodes.length) return <p className="wiki-empty">No concepts to show.</p>;
34
+ return <ul className="wiki-concept-list">
35
+ {nodes.map(node => <li key={conceptId(node)}>
36
+ <ConceptLink demo={demo} node={node} onOpen={onOpen} />
37
+ <span className="wiki-chip">{kindLabel(node.nodeKind)}</span>
38
+ {showEdited && conceptEditedAt(node) && <span className="wiki-meta">edited {formatEdited(conceptEditedAt(node))}</span>}
39
+ </li>)}
40
+ </ul>;
41
+ }
42
+
43
+ function WikiIndexPageView({ demo, nodes, onOpen }: { demo: boolean; nodes: SemanticNodeRecord[]; onOpen: (lineageId: string) => void }) {
44
+ const [page, setPage] = useState<WikiIndexPage | null>(null);
45
+ const [error, setError] = useState<string | null>(null);
46
+ const [saving, setSaving] = useState(false);
47
+ useEffect(() => {
48
+ let cancelled = false;
49
+ loadWikiIndexPage(demo || isLocalPreviewEnabled()).then(loaded => { if (!cancelled) setPage(loaded); }).catch((reason: unknown) => { if (!cancelled) setError(reason instanceof Error ? reason.message : "Could not load the wiki index."); });
50
+ return () => { cancelled = true; };
51
+ }, [demo]);
52
+ const dynamicDirectives = useMemo(() => ({
53
+ "recent-concepts": (attributes: Record<string, unknown>) => <ConceptRows demo={demo} nodes={recentlyEditedConcepts(nodes, { kind: typeof attributes.kind === "string" ? attributes.kind : null, limit: Number(attributes.limit) || 10 })} onOpen={onOpen} showEdited />,
54
+ "concept-list": (attributes: Record<string, unknown>) => <ConceptRows demo={demo} nodes={conceptsByTitle(nodes, { kind: typeof attributes.kind === "string" ? attributes.kind : null, limit: Number(attributes.limit) || null })} onOpen={onOpen} />,
55
+ "concept-count": (attributes: Record<string, unknown>) => <strong>{nodes.filter(node => !attributes.kind || node.nodeKind === attributes.kind).length}</strong>,
56
+ }), [demo, nodes, onOpen]);
57
+ const renderLink = (href: string, children: ReactNode) => {
58
+ const node = resolveConceptLink(href, nodes);
59
+ return node ? <ConceptLink demo={demo} node={node} onOpen={onOpen}>{children}</ConceptLink> : null;
60
+ };
61
+ if (error) return <p className="wiki-empty" role="alert">{error}</p>;
62
+ if (!page) return <p className="wiki-empty">Loading the wiki…</p>;
63
+ return <article className="wiki-page" data-wiki-index>
64
+ <div className="wiki-page-tools">
65
+ {page.itemId
66
+ ? <Link className="wiki-tool-link" href={getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(page.itemId)}`, demo)}>Edit this page</Link>
67
+ : <button className="wiki-tool-link" disabled={saving} onClick={() => { setSaving(true); createWikiIndexPage(demo || isLocalPreviewEnabled()).then(setPage).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Could not save the index.")).finally(() => setSaving(false)); }} type="button">{saving ? "Saving…" : "Save this page so it can be edited"}</button>}
68
+ </div>
69
+ <MarkusIrPreview bodyIr={page.bodyIr} dynamicDirectives={dynamicDirectives} renderLink={renderLink} />
70
+ </article>;
71
+ }
72
+
73
+ function ConceptPageView({ demo, graph, node, nodes, onOpen }: { demo: boolean; graph: SemanticGraph; node: SemanticNodeRecord; nodes: SemanticNodeRecord[]; onOpen: (lineageId: string) => void }) {
74
+ const lineage = conceptId(node);
75
+ const groups = graph.neighbors("semanticNode", lineage);
76
+ const references = graph.referencesForSemanticNode(lineage);
77
+ const byLineage = new Map(nodes.map(candidate => [conceptId(candidate), candidate]));
78
+ const aliases = (node.aliases ?? []).filter(Boolean) as string[];
79
+ return <article className="wiki-page" data-wiki-concept={lineage}>
80
+ <h1>{conceptTitle(node)}</h1>
81
+ <p className="wiki-meta"><span className="wiki-chip">{kindLabel(node.nodeKind)}</span> {node.status !== "current" && <span className="wiki-chip">{node.status}</span>} {conceptEditedAt(node) && <>Last edited {formatEdited(conceptEditedAt(node))}</>}{node.versionCreatedBy ? ` by ${node.versionCreatedBy}` : ""}</p>
82
+ {aliases.length > 0 && <p className="wiki-meta">Also known as {aliases.join(", ")}</p>}
83
+ {node.description ? node.description.split(/\n{2,}/).map((paragraph, index) => <p key={index}>{paragraph}</p>) : <p className="wiki-empty">This concept has no description yet.</p>}
84
+ <section data-wiki-section="connections">
85
+ <h2>Connections</h2>
86
+ {groups.length ? groups.map(group => <div className="wiki-connection-group" key={`${group.direction}-${group.predicate}`}>
87
+ <h3>{group.label}</h3>
88
+ <ul className="wiki-concept-list">
89
+ {group.objects.map(object => {
90
+ const target = object.kind === "semanticNode" ? byLineage.get(object.lineageId) : undefined;
91
+ return <li key={`${object.kind}-${object.lineageId}`}>{target ? <ConceptLink demo={demo} node={target} onOpen={onOpen} /> : <span>{object.label}</span>}<span className="wiki-chip">{target ? kindLabel(target.nodeKind) : object.kind}</span></li>;
92
+ })}
93
+ </ul>
94
+ </div>) : <p className="wiki-empty">No connections recorded.</p>}
95
+ </section>
96
+ <section data-wiki-section="references">
97
+ <h2>References</h2>
98
+ {references.length ? <ul className="wiki-concept-list">
99
+ {references.map(reference => <li key={reference.lineageId}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, demo)}>{reference.label}</Link></li>)}
100
+ </ul> : <p className="wiki-empty">No references recorded.</p>}
101
+ </section>
102
+ </article>;
103
+ }
104
+
105
+ /**
106
+ * The concept wiki: the front page is a content item whose Markus body can hold live components
107
+ * (recently edited concepts, concept lists), and every concept in the knowledge graph is a page
108
+ * linked to the concepts and references it connects to.
109
+ */
110
+ export function WikiConceptsView({ demo = false, graph, initialNodeLineageId, nodes }: { demo?: boolean; graph: SemanticGraph; initialNodeLineageId?: string | null; nodes: SemanticNodeRecord[] }) {
111
+ const [selected, setSelected] = useState<string | null>(initialNodeLineageId ?? null);
112
+ const [search, setSearch] = useState("");
113
+ const [kind, setKind] = useState("");
114
+ useEffect(() => { setSelected(initialNodeLineageId ?? null); }, [initialNodeLineageId]);
115
+ useEffect(() => {
116
+ const onPop = () => {
117
+ const match = window.location.pathname.match(/\/concepts\/([^/?#]+)/);
118
+ setSelected(match ? decodeURIComponent(match[1]) : null);
119
+ };
120
+ window.addEventListener("popstate", onPop);
121
+ return () => window.removeEventListener("popstate", onPop);
122
+ }, []);
123
+ const open = (lineageId: string | null) => {
124
+ setSelected(lineageId);
125
+ const target = getNewsroomNavHref(lineageId ? `/newsroom/concepts/${encodeURIComponent(lineageId)}` : "/newsroom/concepts", demo);
126
+ if (`${window.location.pathname}${window.location.search}` !== target) window.history.pushState(null, "", target);
127
+ };
128
+ const node = selected ? nodes.find(candidate => conceptId(candidate) === selected || candidate.id === selected) ?? null : null;
129
+ const kinds = useMemo(() => Array.from(new Set(nodes.map(candidate => candidate.nodeKind))).sort(), [nodes]);
130
+ const matches = useMemo(() => {
131
+ const needle = search.trim().toLowerCase();
132
+ return conceptsByTitle(nodes, { kind: kind || null }).filter(candidate => !needle || conceptTitle(candidate).toLowerCase().includes(needle) || (candidate.aliases ?? []).some(alias => alias?.toLowerCase().includes(needle)));
133
+ }, [kind, nodes, search]);
134
+ return <div className="wiki" data-wiki>
135
+ <nav aria-label="Wiki" className="wiki-sidebar">
136
+ <button className="wiki-home" data-wiki-home aria-current={node ? undefined : "page"} onClick={() => open(null)} type="button">Wiki home</button>
137
+ <input aria-label="Search concepts" className="wiki-search" onChange={event => setSearch(event.target.value)} placeholder="Search concepts" value={search} />
138
+ {kinds.length > 1 && <select aria-label="Concept kind" className="wiki-kind" onChange={event => setKind(event.target.value)} value={kind}>
139
+ <option value="">All kinds</option>
140
+ {kinds.map(option => <option key={option} value={option}>{kindLabel(option)}</option>)}
141
+ </select>}
142
+ <ul className="wiki-sidebar-list">
143
+ {matches.map(candidate => <li key={conceptId(candidate)}><a aria-current={node && conceptId(node) === conceptId(candidate) ? "page" : undefined} href={getNewsroomNavHref(conceptWikiPath(candidate), demo)} onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey) return; event.preventDefault(); open(conceptId(candidate)); }}>{conceptTitle(candidate)}</a></li>)}
144
+ </ul>
145
+ </nav>
146
+ <div className="wiki-main">
147
+ {node
148
+ ? <ConceptPageView demo={demo} graph={graph} node={node} nodes={nodes} onOpen={open} />
149
+ : selected ? <p className="wiki-empty" role="alert">That concept page was not found.</p>
150
+ : <WikiIndexPageView demo={demo} nodes={nodes} onOpen={open} />}
151
+ </div>
152
+ </div>;
153
+ }
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import type { Schema } from "../amplify/data/resource";
5
+ import { resolveArticlesBackend } from "../lib/newsroom-articles";
6
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
7
+ import { DEFAULT_WIKI_INDEX_MARKUS, WIKI_INDEX_SLUG, WIKI_PAGE_ITEM_TYPE } from "../lib/wiki";
8
+ import { configureAmplifyClient } from "./amplify-client-provider";
9
+
10
+ export type WikiIndexPage = {
11
+ /** The id of the stored content item, or null while the wiki still shows the built-in starter content. */
12
+ itemId: string | null;
13
+ bodyMarkus: string;
14
+ bodyIr: unknown;
15
+ };
16
+
17
+ type GraphqlResponse = { data?: { itemBySlug?: { items?: Record<string, unknown>[] | null } | null } | null; errors?: { message?: string }[] | null };
18
+
19
+ async function storedIndexRow(preview: boolean): Promise<Record<string, unknown> | null> {
20
+ if (preview || isLocalPreviewEnabled()) return null;
21
+ configureAmplifyClient();
22
+ const client = generateClient<Schema>() as unknown as { graphql: (options: Record<string, unknown>) => Promise<GraphqlResponse> };
23
+ const response = await client.graphql({
24
+ query: "query ($slug: String!) { itemBySlug(slug: $slug) { items { id versionNumber versionState bodyMarkus bodyIr } } }",
25
+ variables: { slug: WIKI_INDEX_SLUG },
26
+ authMode: "userPool",
27
+ });
28
+ if (response.errors?.length) throw new Error(response.errors.map(error => error.message ?? "GraphQL error").join("; "));
29
+ const rows = (response.data?.itemBySlug?.items ?? []).filter(Boolean) as Record<string, unknown>[];
30
+ const ranked = rows.sort((left, right) => Number(right.versionNumber ?? 0) - Number(left.versionNumber ?? 0));
31
+ return ranked.find(row => row.versionState === "current") ?? ranked[0] ?? null;
32
+ }
33
+
34
+ async function derive(bodyMarkus: string, preview: boolean): Promise<unknown> {
35
+ const result = await resolveArticlesBackend(preview).actions.deriveMarkus({ frontMatterYaml: null, bodyMarkus, includeIr: true });
36
+ return result.bodyIr ?? null;
37
+ }
38
+
39
+ /** Loads the wiki's front page content item, falling back to the starter content until one is saved. */
40
+ export async function loadWikiIndexPage(preview: boolean): Promise<WikiIndexPage> {
41
+ const row = await storedIndexRow(preview);
42
+ const bodyMarkus = typeof row?.bodyMarkus === "string" && row.bodyMarkus.trim() ? row.bodyMarkus : DEFAULT_WIKI_INDEX_MARKUS;
43
+ const storedIr = row?.bodyIr;
44
+ return { itemId: row ? String(row.id) : null, bodyMarkus, bodyIr: storedIr ?? await derive(bodyMarkus, preview) };
45
+ }
46
+
47
+ /** Saves the starter index as the wiki-index content item so editors can change it like any other page. */
48
+ export async function createWikiIndexPage(preview: boolean): Promise<WikiIndexPage> {
49
+ const saved = await resolveArticlesBackend(preview).actions.saveItemDraft({ id: null, type: WIKI_PAGE_ITEM_TYPE, slug: WIKI_INDEX_SLUG, section: null, frontMatterYaml: `title: Concept wiki\n`, bodyMarkus: DEFAULT_WIKI_INDEX_MARKUS, aliases: [], expectedContentHash: null });
50
+ return { itemId: saved.item.id, bodyMarkus: DEFAULT_WIKI_INDEX_MARKUS, bodyIr: await derive(DEFAULT_WIKI_INDEX_MARKUS, preview) };
51
+ }
@@ -43,11 +43,22 @@ The stack also creates the `<siteId>-papyrus-authoring` role. The CLI signs
43
43
  * AppSync requests (SigV4) with credentials for that role (SSO, OIDC or any
44
44
  * principal in the account that may assume it); no token is stored anywhere.
45
45
  *
46
- * No repository and no access token are configured: the apps are manual-deploy
47
- * until Amplify's GitHub App is connected once in the console
48
- * (docs/site-hosting.md). The Papyrus backend itself is created inside the CMS
46
+ * Unless `repositoryAccessTokenSecretArn` is set, no repository and no access
47
+ * token are configured: the apps are manual-deploy until Amplify's GitHub App is
48
+ * connected once in the console (docs/site-hosting.md). When it is set, both apps
49
+ * are created against `repository` and read the GitHub token from that Secrets
50
+ * Manager secret through a CloudFormation dynamic reference, so the token never
51
+ * appears in site.json or the synthesized template. The Papyrus backend itself is created inside the CMS
49
52
  * app by `ampx pipeline-deploy` from the production build.
50
53
  */
54
+ function repositoryConnectionProperties(config) {
55
+ if (config.repositoryAccessTokenSecretArn === undefined)
56
+ return {};
57
+ return {
58
+ repository: config.repository,
59
+ accessToken: `{{resolve:secretsmanager:${config.repositoryAccessTokenSecretArn}:SecretString}}`,
60
+ };
61
+ }
51
62
  export class AmplifyAppShellStack extends Stack {
52
63
  cmsAppId;
53
64
  readerAppId;
@@ -114,7 +125,9 @@ export class AmplifyAppShellStack extends Stack {
114
125
  resources: PROSPECTUS_PAPYRUS_SITE_ROUTES.map((route) => `${prospectusAccountsApiStageArn}/${route}`),
115
126
  }));
116
127
  }
128
+ const repositoryConnection = repositoryConnectionProperties(config);
117
129
  const cmsApp = new amplify.CfnApp(this, "App", {
130
+ ...repositoryConnection,
118
131
  name: cmsAppName,
119
132
  description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
120
133
  platform: "WEB_COMPUTE",
@@ -332,6 +345,7 @@ export class AmplifyAppShellStack extends Stack {
332
345
  resources: ["arn:aws:s3:::amplify-*/media/*"],
333
346
  }));
334
347
  const readerApp = new amplify.CfnApp(this, "ReaderApp", {
348
+ ...repositoryConnectionProperties(config),
335
349
  name: readerAppName,
336
350
  description: `Papyrus static reader for ${config.siteId} (WEB)`,
337
351
  platform: "WEB",
@@ -46,11 +46,22 @@ The stack also creates the `<siteId>-papyrus-authoring` role. The CLI signs
46
46
  * AppSync requests (SigV4) with credentials for that role (SSO, OIDC or any
47
47
  * principal in the account that may assume it); no token is stored anywhere.
48
48
  *
49
- * No repository and no access token are configured: the apps are manual-deploy
50
- * until Amplify's GitHub App is connected once in the console
51
- * (docs/site-hosting.md). The Papyrus backend itself is created inside the CMS
49
+ * Unless `repositoryAccessTokenSecretArn` is set, no repository and no access
50
+ * token are configured: the apps are manual-deploy until Amplify's GitHub App is
51
+ * connected once in the console (docs/site-hosting.md). When it is set, both apps
52
+ * are created against `repository` and read the GitHub token from that Secrets
53
+ * Manager secret through a CloudFormation dynamic reference, so the token never
54
+ * appears in site.json or the synthesized template. The Papyrus backend itself is created inside the CMS
52
55
  * app by `ampx pipeline-deploy` from the production build.
53
56
  */
57
+ function repositoryConnectionProperties(config: AmplifyAppShellSiteConfig): { repository?: string; accessToken?: string } {
58
+ if (config.repositoryAccessTokenSecretArn === undefined) return {};
59
+ return {
60
+ repository: config.repository,
61
+ accessToken: `{{resolve:secretsmanager:${config.repositoryAccessTokenSecretArn}:SecretString}}`,
62
+ };
63
+ }
64
+
54
65
  export class AmplifyAppShellStack extends Stack {
55
66
  public readonly cmsAppId?: string;
56
67
  public readonly readerAppId?: string;
@@ -130,7 +141,10 @@ export class AmplifyAppShellStack extends Stack {
130
141
  );
131
142
  }
132
143
 
144
+ const repositoryConnection = repositoryConnectionProperties(config);
145
+
133
146
  const cmsApp = new amplify.CfnApp(this, "App", {
147
+ ...repositoryConnection,
134
148
  name: cmsAppName,
135
149
  description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
136
150
  platform: "WEB_COMPUTE",
@@ -389,6 +403,7 @@ export class AmplifyAppShellStack extends Stack {
389
403
  );
390
404
 
391
405
  const readerApp = new amplify.CfnApp(this, "ReaderApp", {
406
+ ...repositoryConnectionProperties(config),
392
407
  name: readerAppName,
393
408
  description: `Papyrus static reader for ${config.siteId} (WEB)`,
394
409
  platform: "WEB",
@@ -5,6 +5,7 @@ export function hasCmsApp(config) {
5
5
  export const DEFAULT_CI_BRANCHES = ["main", "staging"];
6
6
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
7
7
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
8
+ const SECRETS_MANAGER_SECRET_ARN_PATTERN = /^arn:aws:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+$/;
8
9
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
9
10
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
10
11
  const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
@@ -297,9 +298,12 @@ function validateOauthRedirectUrls(config) {
297
298
  }
298
299
  export function parseSiteConfig(raw) {
299
300
  const record = requireObject(raw, "site");
300
- rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
301
+ rejectUnknownKeys(record, ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
301
302
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
302
303
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
304
+ const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
305
+ ? undefined
306
+ : requireString(record.repositoryAccessTokenSecretArn, "repositoryAccessTokenSecretArn", SECRETS_MANAGER_SECRET_ARN_PATTERN, "Secrets Manager secret ARN");
303
307
  const brand = requireString(record.brand, "brand");
304
308
  const frontend = requireString(record.frontend, "frontend");
305
309
  if (frontend !== "pretext" && frontend !== "markus-static") {
@@ -382,6 +386,7 @@ export function parseSiteConfig(raw) {
382
386
  const config = {
383
387
  siteId,
384
388
  repository,
389
+ repositoryAccessTokenSecretArn,
385
390
  brand,
386
391
  frontend,
387
392
  hostedZoneId,
@@ -47,6 +47,7 @@ export type CmsConfig = {
47
47
  export type AmplifyAppShellSiteConfig = {
48
48
  siteId: string;
49
49
  repository: string;
50
+ repositoryAccessTokenSecretArn?: string;
50
51
  brand: string;
51
52
  frontend: "pretext" | "markus-static";
52
53
  hostedZoneId?: string;
@@ -85,6 +86,7 @@ export const DEFAULT_CI_BRANCHES = ["main", "staging"];
85
86
 
86
87
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
87
88
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
89
+ const SECRETS_MANAGER_SECRET_ARN_PATTERN = /^arn:aws:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+$/;
88
90
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
89
91
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
90
92
  const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
@@ -392,12 +394,15 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
392
394
  const record = requireObject(raw, "site");
393
395
  rejectUnknownKeys(
394
396
  record,
395
- ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
397
+ ["siteId", "repository", "repositoryAccessTokenSecretArn", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
396
398
  "site",
397
399
  );
398
400
 
399
401
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
400
402
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
403
+ const repositoryAccessTokenSecretArn = record.repositoryAccessTokenSecretArn === undefined
404
+ ? undefined
405
+ : requireString(record.repositoryAccessTokenSecretArn, "repositoryAccessTokenSecretArn", SECRETS_MANAGER_SECRET_ARN_PATTERN, "Secrets Manager secret ARN");
401
406
  const brand = requireString(record.brand, "brand");
402
407
  const frontend = requireString(record.frontend, "frontend");
403
408
  if (frontend !== "pretext" && frontend !== "markus-static") {
@@ -480,6 +485,7 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
480
485
  const config: AmplifyAppShellSiteConfig = {
481
486
  siteId,
482
487
  repository,
488
+ repositoryAccessTokenSecretArn,
483
489
  brand,
484
490
  frontend,
485
491
  hostedZoneId,
@@ -1,6 +1,9 @@
1
+ import approvedReport0 from "./approved-priming-proofs/papyrus.json";
2
+
1
3
  /**
2
4
  * Priming proof reports a publication owner approved for visitors, keyed by site brand id.
3
- * A report reaches this map only through a reviewed commit of its
4
- * `publications/priming-proofs/<brand id>.json` file; see docs/cyclotron-priming.md.
5
+ * A report reaches this map only through a reviewed commit made by scripts/approve-priming-proof-report.ts.
5
6
  */
6
- export const APPROVED_PRIMING_PROOF_REPORTS: Record<string, unknown> = {};
7
+ export const APPROVED_PRIMING_PROOF_REPORTS: Record<string, unknown> = {
8
+ "papyrus": approvedReport0,
9
+ };