@anthusai/papyrus 1.4.0-next.4 → 1.4.0-next.6

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 (69) hide show
  1. package/amplify/data/resource.js +13 -3
  2. package/amplify/data/resource.ts +13 -3
  3. package/amplify/data/schema.js +10 -0
  4. package/amplify/data/schema.ts +10 -0
  5. package/amplify/functions/category-action/handler.ts +20 -19
  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/renditions/requirements.txt +1 -1
  12. package/amplify/functions/shared/reference-curation-metadata.ts +15 -0
  13. package/amplify/functions/shared/slack-events.ts +2 -2
  14. package/amplify/publication/renditions-function.js +1 -1
  15. package/amplify/publication/renditions-function.ts +1 -1
  16. package/app/api/local-preview/derive/route.ts +28 -0
  17. package/app/api/local-preview/media/route.ts +25 -0
  18. package/app/globals.css +28 -28
  19. package/app/layout.tsx +8 -5
  20. package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
  21. package/app/newsroom/[section]/page.tsx +0 -1
  22. package/app/newsroom/insights/[threadId]/page.tsx +5 -16
  23. package/app/newsroom/insights/page.tsx +4 -12
  24. package/components/board-card-excerpts.tsx +45 -0
  25. package/components/markus-ir-preview.tsx +47 -2
  26. package/components/news-desk-page.tsx +0 -1
  27. package/components/news-desk-taxonomy-client.ts +25 -220
  28. package/components/newsroom-article-editor.tsx +135 -39
  29. package/components/newsroom-assignment-detail.tsx +35 -1
  30. package/components/newsroom-board-view.tsx +29 -7
  31. package/components/newsroom-newsprint-board.css +10 -2
  32. package/components/newsroom-ops-overview.tsx +27 -5
  33. package/components/newsroom-organizer-shell.tsx +25 -7
  34. package/components/newsroom-organizer.css +196 -4
  35. package/components/priming-proof-page.tsx +17 -8
  36. package/components/prospectus-account-chooser.tsx +98 -0
  37. package/components/story-reporting-trail-client.ts +91 -0
  38. package/components/story-reporting-trail.tsx +115 -0
  39. package/components/topic-steering-workspace.tsx +154 -671
  40. package/components/ui/sheet.tsx +1 -0
  41. package/components/wiki-concepts-view.tsx +153 -0
  42. package/components/wiki-index-client.ts +51 -0
  43. package/lib/content-actions-client.ts +3 -0
  44. package/lib/content-actions-demo.ts +55 -3
  45. package/lib/content-item-lifecycle.ts +4 -2
  46. package/lib/content-source-context.ts +9 -1
  47. package/lib/front-matter-fields.ts +42 -0
  48. package/lib/graphql-content-repository.ts +42 -7
  49. package/lib/local-preview-seed.ts +55 -2
  50. package/lib/local-preview-tenant.ts +27 -0
  51. package/lib/newsroom-articles.ts +2 -0
  52. package/lib/newsroom-board.ts +4 -0
  53. package/lib/newsroom-forum-routes.ts +2 -43
  54. package/lib/newsroom-index-filters.ts +3 -40
  55. package/lib/newsroom-media-backend.ts +5 -0
  56. package/lib/newsroom-nav.ts +1 -3
  57. package/lib/newsroom-organizer-nav.ts +5 -12
  58. package/lib/newsroom-section-routing.ts +1 -1
  59. package/lib/newsroom-sections.ts +12 -0
  60. package/lib/papyrus-web-locations.ts +3 -24
  61. package/lib/priming-proof-report.ts +1 -0
  62. package/lib/prospectus-tenant.ts +22 -0
  63. package/lib/semantic-graph.ts +5 -5
  64. package/lib/site-brand.ts +9 -0
  65. package/lib/story-reporting-trail.ts +106 -0
  66. package/lib/tenant-keys.ts +32 -0
  67. package/lib/wiki.ts +56 -0
  68. package/package.json +15 -1
  69. package/routes.manifest.json +23 -7
@@ -45,6 +45,7 @@ function SheetContent({
45
45
  <DialogPrimitive.Backdrop
46
46
  className={cn(
47
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",
48
49
  backdropClassName,
49
50
  )}
50
51
  />
@@ -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
+ }
@@ -13,6 +13,9 @@ export type SaveItemDraftInput = {
13
13
  bodyMarkus: string;
14
14
  aliases: string[];
15
15
  expectedContentHash: string | null;
16
+ /** Records a new draft version of the story instead of correcting the current one in place. */
17
+ newVersion?: boolean;
18
+ changeReason?: string | null;
16
19
  };
17
20
 
18
21
  export type DeriveMarkusResult = { ok: true; bodyIrBytes: number; bodyIr?: unknown; errors: [] };
@@ -1,4 +1,5 @@
1
1
  import { ContentActionFailure } from "./content-actions-client";
2
+ import { isLocalPreviewEnabled } from "./local-preview";
2
3
  import type {
3
4
  DeriveMarkusInput,
4
5
  DeriveMarkusResult,
@@ -12,7 +13,7 @@ import {
12
13
  demoContentHash,
13
14
  type DemoArticleRecord,
14
15
  } from "./newsroom-demo-articles";
15
- import { createLocalPreviewEditableArticle } from "./local-preview-seed";
16
+ import { createLocalPreviewEditableArticle, lineageForPreviewVersion, recordLocalPreviewDesk, recordLocalPreviewVersion } from "./local-preview-seed";
16
17
  import { sourceLineOffset, type ArticlesBackend, type EditableArticle, type NewsroomArticleRow } from "./newsroom-articles";
17
18
 
18
19
  function titleFromYaml(frontMatterYaml: string | null, fallback: string): string {
@@ -30,6 +31,18 @@ function demoAttribute(attributes: string, name: string): string {
30
31
  return match ? match[1] : "";
31
32
  }
32
33
 
34
+ async function deriveThroughPreviewMarkusEngine(input: DeriveMarkusInput): Promise<DeriveMarkusResult | null> {
35
+ try {
36
+ const response = await fetch("/api/local-preview/derive", { method: "POST", body: JSON.stringify({ frontMatterYaml: input.frontMatterYaml, bodyMarkus: input.bodyMarkus }) });
37
+ if (!response.ok) return null;
38
+ const result = await response.json() as DeriveMarkusResult & { error?: string };
39
+ if (result.error) return null;
40
+ return input.includeIr ? result : { ...result, bodyIr: undefined };
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
33
46
  function demoEnvelope(bodyMarkus: string) {
34
47
  const images: Record<string, Record<string, unknown>> = {};
35
48
  const children = bodyMarkus
@@ -74,6 +87,10 @@ export function createDemoArticlesBackend(): ArticlesBackend {
74
87
  return {
75
88
  actions: {
76
89
  async deriveMarkus(input: DeriveMarkusInput): Promise<DeriveMarkusResult> {
90
+ if (isLocalPreviewEnabled()) {
91
+ const derived = await deriveThroughPreviewMarkusEngine(input);
92
+ if (derived) return derived;
93
+ }
77
94
  const lines = input.bodyMarkus.split("\n");
78
95
  const invalidIndex = lines.findIndex((line) => line.includes(":::nope"));
79
96
  if (invalidIndex >= 0) {
@@ -91,6 +108,28 @@ export function createDemoArticlesBackend(): ArticlesBackend {
91
108
  },
92
109
  async saveItemDraft(input: SaveItemDraftInput): Promise<SaveItemDraftResult> {
93
110
  const existing = input.id ? requireRecord(input.id) : null;
111
+ if (input.newVersion && existing) {
112
+ const lineageId = createLocalPreviewEditableArticle(existing.id)?.lineageId ?? existing.id;
113
+ const versionNumber = (existing.versionNumber ?? 1) + 1;
114
+ const id = `${lineageId}-v${versionNumber}`;
115
+ const frontMatterYaml = input.frontMatterYaml ?? "";
116
+ const record: DemoArticleRecord = {
117
+ ...existing,
118
+ id,
119
+ slug: existing.slug,
120
+ section: input.section,
121
+ title: titleFromYaml(input.frontMatterYaml, existing.slug),
122
+ status: "draft",
123
+ frontMatterYaml,
124
+ bodyMarkus: input.bodyMarkus,
125
+ contentHash: demoContentHash(frontMatterYaml, input.bodyMarkus),
126
+ versionNumber,
127
+ updatedAt: new Date().toISOString(),
128
+ };
129
+ records.set(id, record);
130
+ recordLocalPreviewVersion(lineageId, { id, versionNumber, versionState: "current", status: "draft", stage: null, headline: record.title, changeReason: input.changeReason ?? null, versionCreatedBy: "You", versionCreatedAt: record.updatedAt, updatedAt: record.updatedAt });
131
+ return { ok: true, item: { id, contentHash: record.contentHash, status: "draft", slug: record.slug, versionNumber } };
132
+ }
94
133
  if (existing && input.expectedContentHash !== null && input.expectedContentHash !== existing.contentHash) {
95
134
  throw new ContentActionFailure([
96
135
  { code: "conflict", message: "The item changed since it was loaded.", line: null },
@@ -120,6 +159,7 @@ export function createDemoArticlesBackend(): ArticlesBackend {
120
159
  updatedAt: new Date().toISOString(),
121
160
  };
122
161
  records.set(id, record);
162
+ recordLocalPreviewDesk(id, record.section);
123
163
  return {
124
164
  ok: true,
125
165
  item: {
@@ -172,11 +212,23 @@ export function createDemoArticlesBackend(): ArticlesBackend {
172
212
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
173
213
  },
174
214
  async loadArticle(id: string): Promise<EditableArticle> {
175
- const previewRecord = records.get(id) ? null : createLocalPreviewEditableArticle(id);
176
- if (previewRecord) return previewRecord;
215
+ if (!records.has(id)) {
216
+ const previewRecord = createLocalPreviewEditableArticle(id);
217
+ if (previewRecord) {
218
+ records.set(id, {
219
+ ...previewRecord,
220
+ title: titleFromYaml(previewRecord.frontMatterYaml, previewRecord.slug),
221
+ status: previewRecord.status === "published" ? "published" : "draft",
222
+ contentHash: demoContentHash(previewRecord.frontMatterYaml, previewRecord.bodyMarkus),
223
+ publishedContentHash: previewRecord.status === "published" ? demoContentHash(previewRecord.frontMatterYaml, previewRecord.bodyMarkus) : null,
224
+ updatedAt: new Date().toISOString(),
225
+ });
226
+ }
227
+ }
177
228
  const record = requireRecord(id);
178
229
  return {
179
230
  id: record.id,
231
+ lineageId: createLocalPreviewEditableArticle(record.id)?.lineageId ?? lineageForPreviewVersion(record.id),
180
232
  type: record.type,
181
233
  section: record.section,
182
234
  slug: record.slug,
@@ -28,9 +28,9 @@ export const DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS: readonly ContentItemStageDe
28
28
  { key: "copywriting", label: "Copywriting" },
29
29
  { key: "illustration", label: "Illustration" },
30
30
  { key: "typesetting", label: "Typesetting" },
31
- { key: "published", label: "Published" },
32
- { key: "held", label: "Held" },
33
31
  { key: "spiked", label: "Spiked", collapsedUntilOpened: true },
32
+ { key: "held", label: "Held" },
33
+ { key: "published", label: "Published" },
34
34
  ];
35
35
 
36
36
  export function contentItemStageKeys(stageDefinitions: readonly ContentItemStageDefinition[]): string[] {
@@ -68,6 +68,8 @@ export type ContentItemVersionRecord = {
68
68
  status: string;
69
69
  title?: string | null;
70
70
  headline?: string | null;
71
+ deck?: string | null;
72
+ metadata?: unknown;
71
73
  slug: string;
72
74
  section?: string | null;
73
75
  updatedAt?: string | null;
@@ -1,10 +1,13 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { getContentSource, type ContentSource } from "./site-env";
3
+ import { tenantIdFromEnvironment } from "./tenant-keys";
3
4
 
4
5
  export type ContentSourceContext = {
5
6
  source: ContentSource;
6
7
  /** Request-scoped data client (drafts mode: carries the visitor's session cookies). */
7
8
  client?: unknown;
9
+ /** Tenant of the selected Prospectus account; falls back to PAPYRUS_TENANT_ID. */
10
+ tenantId?: string;
8
11
  };
9
12
 
10
13
  const contentSourceStorage = new AsyncLocalStorage<ContentSourceContext>();
@@ -13,8 +16,13 @@ export function runWithContentSource<T>(
13
16
  source: ContentSource,
14
17
  operation: () => Promise<T>,
15
18
  client?: unknown,
19
+ tenantId?: string,
16
20
  ): Promise<T> {
17
- return contentSourceStorage.run({ source, client }, operation);
21
+ return contentSourceStorage.run({ source, client, tenantId }, operation);
22
+ }
23
+
24
+ export function currentTenantId(): string | undefined {
25
+ return contentSourceStorage.getStore()?.tenantId ?? tenantIdFromEnvironment();
18
26
  }
19
27
 
20
28
  export function currentRequestClient<T>(): T | undefined {
@@ -0,0 +1,42 @@
1
+ import { parseDocument } from "yaml";
2
+ import type { ContentItemVersionRecord } from "./content-item-lifecycle";
3
+
4
+ /** Front matter keys the editor shows as form fields instead of raw YAML. */
5
+ export const FRONT_MATTER_TITLE_KEY = "title";
6
+ export const FRONT_MATTER_SUBTITLE_KEY = "deck";
7
+ export const FRONT_MATTER_BLURB_KEY = "blurb";
8
+
9
+ /** Reads one text field from front matter YAML; invalid YAML or a non-text value reads as empty. */
10
+ export function readFrontMatterText(frontMatterYaml: string | null | undefined, key: string): string {
11
+ if (!frontMatterYaml?.trim()) return "";
12
+ const document = parseDocument(frontMatterYaml);
13
+ if (document.errors.length) return "";
14
+ const value = document.get(key);
15
+ return typeof value === "string" ? value : "";
16
+ }
17
+
18
+ /** Sets or removes one text field in front matter YAML, keeping every other line and comment as written. */
19
+ export function writeFrontMatterText(frontMatterYaml: string | null | undefined, key: string, value: string): string {
20
+ const document = parseDocument(frontMatterYaml?.trim() ? frontMatterYaml : "{}\n");
21
+ if (document.errors.length) return frontMatterYaml ?? "";
22
+ if (value.trim()) document.set(key, value);
23
+ else document.delete(key);
24
+ const text = document.toString();
25
+ return text.trim() === "{}" ? "" : text;
26
+ }
27
+
28
+ function metadataFrontMatterYaml(metadata: unknown): string {
29
+ const parsed = typeof metadata === "string" ? safeJson(metadata) : metadata;
30
+ const text = (parsed as { frontMatterYaml?: unknown } | null)?.frontMatterYaml;
31
+ return typeof text === "string" ? text : "";
32
+ }
33
+
34
+ function safeJson(text: string): unknown {
35
+ try { return JSON.parse(text); } catch { return null; }
36
+ }
37
+
38
+ /** A few lines for a board card: the blurb when one is recorded, else the subtitle. */
39
+ export function contentItemExcerpt(version: Pick<ContentItemVersionRecord, "deck" | "metadata">): string | undefined {
40
+ const blurb = readFrontMatterText(metadataFrontMatterYaml(version.metadata), FRONT_MATTER_BLURB_KEY).trim();
41
+ return blurb || version.deck?.trim() || undefined;
42
+ }
@@ -20,7 +20,9 @@ import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type
20
20
  import { SITE_BRAND } from "./site-brand";
21
21
  import { shouldFetchVideoScripts } from "./video-mode";
22
22
  import { collectVideoScriptTargets, parseVideoScriptRef, videomlItemSlug, type VideoScriptRef } from "./video-script";
23
- import { currentContentSource, currentRequestClient, runWithContentSource } from "./content-source-context";
23
+ import { selectedTenantIdFromCookies } from "./prospectus-tenant";
24
+ import { currentContentSource, currentRequestClient, currentTenantId, runWithContentSource } from "./content-source-context";
25
+ import { rowBelongsToTenant, tenantScopedValue } from "./tenant-keys";
24
26
  import { withRateLimitRetry } from "./rate-limit-retry";
25
27
  import { getContentSource } from "./site-env";
26
28
 
@@ -70,6 +72,7 @@ type GraphQLEditionItem = {
70
72
 
71
73
  type GraphQLItem = {
72
74
  id: string;
75
+ tenantId?: string | null;
73
76
  versionNumber?: number | null;
74
77
  type: string;
75
78
  status: string;
@@ -152,9 +155,10 @@ async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise
152
155
  // Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
153
156
  // kept in the request's async context (never a module global) so one visitor's session cannot serve another.
154
157
  const { cookies } = await import("next/headers");
155
- await cookies();
158
+ const cookieStore = await cookies();
156
159
  const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
157
- return runWithContentSource(source, operation, client);
160
+ const selectedTenantId = await selectedTenantIdFromCookies(cookieStore);
161
+ return runWithContentSource(source, operation, client, selectedTenantId);
158
162
  }
159
163
  return withRateLimitRetry(() =>
160
164
  runWithGuestAmplifyContext(() =>
@@ -587,6 +591,10 @@ type ItemSourceModel = {
587
591
  listPublishedItemsByTypeStatusAndPublishedAt: (input: { typeStatus: string }, options: ListOptions) => ListResponse<GraphQLItem>;
588
592
  };
589
593
 
594
+ type TenantSlugSourceModel = {
595
+ publishedItemByTenantSlug: (input: { tenantSlug: string }, options: ListOptions) => ListResponse<GraphQLItem>;
596
+ };
597
+
590
598
  type MediaAssetSourceModel = {
591
599
  listPublishedMediaAssetsByItemAndSortKey: (
592
600
  input: { publishedItemId: string },
@@ -671,20 +679,33 @@ function getPublishedEditionItemModel(): EditionItemSourceModel {
671
679
  return model as unknown as EditionItemSourceModel;
672
680
  }
673
681
 
682
+ async function getItemWithinTenant(
683
+ tenantId: string | undefined,
684
+ lookup: Promise<GraphQLGetResponse<GraphQLItem>>,
685
+ ): Promise<GraphQLGetResponse<GraphQLItem>> {
686
+ const response = await lookup;
687
+ return rowBelongsToTenant(tenantId, response.data) ? response : { ...response, data: null };
688
+ }
689
+
674
690
  function getPublishedItemModel(): ItemSourceModel {
691
+ const tenantId = currentTenantId();
675
692
  if (currentContentSource() === "drafts") {
676
693
  const model = getClient().models.Item;
677
694
  if (!model) throw missingProjectionModelError("Item");
678
695
  return {
679
- get: (input, options) => model.get(input, options) as Promise<GraphQLGetResponse<GraphQLItem>>,
680
- publishedItemBySlug: (input, options) => model.itemBySlug(input, options) as ListResponse<GraphQLItem>,
696
+ get: (input, options) =>
697
+ getItemWithinTenant(tenantId, model.get(input, options) as Promise<GraphQLGetResponse<GraphQLItem>>),
698
+ publishedItemBySlug: ({ slug }, options) =>
699
+ (tenantId
700
+ ? model.itemByTenantSlug({ tenantSlug: tenantScopedValue(tenantId, slug) }, options)
701
+ : model.itemBySlug({ slug }, options)) as ListResponse<GraphQLItem>,
681
702
  listPublishedItemsByTypeStatusAndPublishedAt: ({ typeStatus }, options) => {
682
703
  const itemType = typeStatus.split("#")[0];
683
704
  return listMergedAcrossStatuses<GraphQLItem>(
684
705
  [DRAFT_STATUS, PUBLISHED_STATUS],
685
706
  (status, pageOptions) =>
686
707
  model.listItemsByTypeStatusAndPublishedAt(
687
- { typeStatus: `${itemType}#${status}` },
708
+ { typeStatus: tenantScopedValue(tenantId, `${itemType}#${status}`) },
688
709
  pageOptions,
689
710
  ) as ListResponse<GraphQLItem>,
690
711
  (item) => item.publishedAt ?? "",
@@ -695,7 +716,21 @@ function getPublishedItemModel(): ItemSourceModel {
695
716
  }
696
717
  const model = getClient().models.PublishedItem;
697
718
  if (!model) throw missingProjectionModelError("PublishedItem");
698
- return model as unknown as ItemSourceModel;
719
+ const publishedModel = model as unknown as ItemSourceModel;
720
+ if (!tenantId) return publishedModel;
721
+ return {
722
+ get: (input, options) => getItemWithinTenant(tenantId, publishedModel.get(input, options)),
723
+ publishedItemBySlug: ({ slug }, options) =>
724
+ (model as unknown as TenantSlugSourceModel).publishedItemByTenantSlug(
725
+ { tenantSlug: tenantScopedValue(tenantId, slug) },
726
+ options,
727
+ ),
728
+ listPublishedItemsByTypeStatusAndPublishedAt: ({ typeStatus }, options) =>
729
+ publishedModel.listPublishedItemsByTypeStatusAndPublishedAt(
730
+ { typeStatus: tenantScopedValue(tenantId, typeStatus) },
731
+ options,
732
+ ),
733
+ };
699
734
  }
700
735
 
701
736
  function getPublishedMediaAssetModel(): MediaAssetSourceModel {
@@ -1,12 +1,15 @@
1
1
  import type { AssignmentRecord, ReferenceRecord } from "./category-repository";
2
2
  import type { EditableArticle } from "./newsroom-articles";
3
+ import type { MediaAssetRecord } from "./newsroom-media";
4
+ import { EMPTY_STORY_REPORTING_TRAIL, type StoryReportingTrail, type TrailVersion } from "./story-reporting-trail";
3
5
  import { contentItemStageKeys, type ContentItemVersionRecord } from "./content-item-lifecycle";
4
6
  import { getContentItemStageDefinitions } from "./site-brand";
7
+ import { LOCAL_PREVIEW_TENANTS, getLocalPreviewTenantId } from "./local-preview-tenant";
5
8
 
6
9
  const SEED_TIMESTAMP_BASE = Date.parse("2026-10-01T12:00:00.000Z");
7
10
  const ONE_HOUR_IN_MILLISECONDS = 60 * 60 * 1000;
8
11
 
9
- const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION: readonly (readonly string[])[] = [
12
+ const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ZERO: readonly (readonly string[])[] = [
10
13
  ["Three sources confirm the zoning reversal", "Reporting packet: school board redistricting"],
11
14
  ["Draft in progress: the last ferry crossing", "Draft in progress: neighborhood bakery profile"],
12
15
  ["Illustration brief: flooded streets, aerial view", "Illustration brief: the market at dawn"],
@@ -16,6 +19,24 @@ const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION: readonly (readonly string[])[] =
16
19
  ["Spiked: the unconfirmed council rumor", "Spiked: the recycled press release"],
17
20
  ];
18
21
 
22
+ const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ONE: readonly (readonly string[])[] = [
23
+ ["Field note: spore dispersal on a damp morning", "Field note: the dung beetle census"],
24
+ ["Draft in progress: why fungi throw their caps", "Draft in progress: the compost experiment"],
25
+ ["Illustration brief: a cap in flight, close up", "Illustration brief: the mycelium network"],
26
+ ["Layout pass: the monthly field guide", "Layout pass: the reader mailbag"],
27
+ ["Published: a season of mushroom sightings", "Published: how the forest floor recycles"],
28
+ ["Held for spring: the truffle hunt", "Evergreen: a beginner's guide to identification"],
29
+ ["Spiked: the unverified foraging claim", "Spiked: the duplicate field report"],
30
+ ];
31
+
32
+ const SEED_CONTENT_ITEM_HEADLINES_BY_TENANT = [SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ZERO, SEED_CONTENT_ITEM_HEADLINES_BY_POSITION_FOR_TENANT_ONE] as const;
33
+
34
+ function headlinesForSelectedTenant(): readonly (readonly string[])[] {
35
+ const tenantId = getLocalPreviewTenantId();
36
+ const tenant = LOCAL_PREVIEW_TENANTS.find(candidate => candidate.accountId === tenantId) ?? LOCAL_PREVIEW_TENANTS[0];
37
+ return SEED_CONTENT_ITEM_HEADLINES_BY_TENANT[tenant.fixtureHeadlineSet] ?? SEED_CONTENT_ITEM_HEADLINES_BY_TENANT[0];
38
+ }
39
+
19
40
  function isoTimestampHoursBeforeBase(hours: number): string {
20
41
  return new Date(SEED_TIMESTAMP_BASE - hours * ONE_HOUR_IN_MILLISECONDS).toISOString();
21
42
  }
@@ -27,7 +48,8 @@ function slugFromHeadline(headline: string): string {
27
48
  export function createLocalPreviewContentItemVersions(): ContentItemVersionRecord[] {
28
49
  const stageKeys = contentItemStageKeys(getContentItemStageDefinitions());
29
50
  return stageKeys.flatMap((stageKey, stageIndex) => {
30
- const headlines = SEED_CONTENT_ITEM_HEADLINES_BY_POSITION[stageIndex % SEED_CONTENT_ITEM_HEADLINES_BY_POSITION.length];
51
+ const tenantHeadlines = headlinesForSelectedTenant();
52
+ const headlines = tenantHeadlines[stageIndex % tenantHeadlines.length];
31
53
  return headlines.map((headline, headlineIndex) => {
32
54
  const slug = slugFromHeadline(headline);
33
55
  return {
@@ -95,12 +117,17 @@ export function createLocalPreviewAssignments(): AssignmentRecord[] {
95
117
  return [...assignmentsOnContentItems, ...assignmentsWithoutContentItem];
96
118
  }
97
119
 
120
+ export function createLocalPreviewRealPublishedVersions(): ContentItemVersionRecord[] {
121
+ return [];
122
+ }
123
+
98
124
  export function createLocalPreviewEditableArticle(id: string): EditableArticle | null {
99
125
  const version = createLocalPreviewContentItemVersions().find(candidate => candidate.id === id);
100
126
  if (!version) return null;
101
127
  const headline = version.headline || version.title || version.slug;
102
128
  return {
103
129
  id: version.id,
130
+ lineageId: version.lineageId,
104
131
  type: version.type,
105
132
  section: version.section ?? null,
106
133
  slug: version.slug,
@@ -113,3 +140,29 @@ export function createLocalPreviewEditableArticle(id: string): EditableArticle |
113
140
  everPublished: version.status === "published",
114
141
  };
115
142
  }
143
+
144
+ export function recordLocalPreviewDesk(articleId: string, deskId: string | null): void {
145
+ const version = createLocalPreviewContentItemVersions().find(candidate => candidate.id === articleId);
146
+ if (version) version.section = deskId;
147
+ }
148
+
149
+ const previewSavedVersions = new Map<string, TrailVersion[]>();
150
+ const previewLineageByVersionId = new Map<string, string>();
151
+
152
+ export function recordLocalPreviewVersion(lineageId: string, version: TrailVersion): void {
153
+ previewSavedVersions.set(lineageId, [version, ...(previewSavedVersions.get(lineageId) ?? [])]);
154
+ previewLineageByVersionId.set(version.id, lineageId);
155
+ }
156
+
157
+ export function lineageForPreviewVersion(versionId: string): string | null {
158
+ return previewLineageByVersionId.get(versionId) ?? null;
159
+ }
160
+
161
+ export function createLocalPreviewReportingTrail(lineageId: string): StoryReportingTrail {
162
+ const saved = previewSavedVersions.get(lineageId) ?? [];
163
+ return { ...EMPTY_STORY_REPORTING_TRAIL, versions: saved };
164
+ }
165
+
166
+ export function createLocalPreviewMediaAssets(_itemId: string): MediaAssetRecord[] {
167
+ return [];
168
+ }