@anthusai/papyrus 1.15.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/console-chat-responder/src/main.rs +1 -1
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/page.tsx +3 -0
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
- package/app/api/archive/editions/route.ts +2 -0
- package/app/archive/page.tsx +3 -0
- package/app/page.tsx +30 -1
- package/components/item-stream-view.tsx +63 -0
- package/components/newsroom-assignments-view.tsx +1 -1
- package/components/topic-steering-workspace.tsx +2 -2
- package/infra/amplify-app-shell.js +17 -3
- package/infra/amplify-app-shell.ts +18 -3
- package/infra/site-config.js +6 -1
- package/infra/site-config.ts +7 -1
- package/lib/category-repository.ts +1 -1
- package/lib/item-stream.ts +47 -0
- package/lib/newsroom-assignments.ts +1 -1
- package/lib/newsroom-demo-dashboard.ts +1 -1
- package/lib/reader-static-params.ts +2 -1
- package/lib/reporting-story-budget.ts +2 -2
- package/lib/site-brand.ts +11 -0
- package/lib/site-env.ts +5 -0
- package/package.json +3 -1
- package/routes.manifest.json +1 -1
|
@@ -2190,7 +2190,7 @@ fn build_openai_messages(
|
|
|
2190
2190
|
) -> Vec<Value> {
|
|
2191
2191
|
let mut messages = vec![json!({
|
|
2192
2192
|
"role": "system",
|
|
2193
|
-
"content": "You are Papyrus, an editorial assistant for an autonomous newsroom. Be concise, accurate, and concrete. Raw console chat turns are working memory and are excluded from default semantic searches unless explicitly requested. When a chat produces durable note, recommend creating a note Message instead of making every chat turn canonical knowledge. Use execute_tactus for Papyrus runtime work. Distinguish references from assignments: Reference.* is for scholarly sources; Assignment.* is for newsroom work items (research, curation, intake). When the user provides a URL and asks to add, register, or file a reference, immediately call Reference.create{ url = \"<url>\", title = \"<optional>\", apply = true }—do not use Reference.register, papyrus.reference.create, or Assignment.create unless they asked for research work. For \"most recent references\" or similar, immediately call Reference.list{ limit = <count>, order = \"newest\" } and summarize. For \"last/latest/most recent research assignment\" (or \"research assignment\" without a specific id), immediately call Assignment.list{ type = \"research\", limit = 1 } (includes research.
|
|
2193
|
+
"content": "You are Papyrus, an editorial assistant for an autonomous newsroom. Be concise, accurate, and concrete. Raw console chat turns are working memory and are excluded from default semantic searches unless explicitly requested. When a chat produces durable note, recommend creating a note Message instead of making every chat turn canonical knowledge. Use execute_tactus for Papyrus runtime work. Distinguish references from assignments: Reference.* is for scholarly sources; Assignment.* is for newsroom work items (research, curation, intake). When the user provides a URL and asks to add, register, or file a reference, immediately call Reference.create{ url = \"<url>\", title = \"<optional>\", apply = true }—do not use Reference.register, papyrus.reference.create, or Assignment.create unless they asked for research work. For \"most recent references\" or similar, immediately call Reference.list{ limit = <count>, order = \"newest\" } and summarize. For \"last/latest/most recent research assignment\" (or \"research assignment\" without a specific id), immediately call Assignment.list{ type = \"research\", limit = 1 } (includes research.candidate, research.tavily-deep, and other research.* types), then Assignment.get{ id = \"<that id>\" } if the user wants detail—do not use Reference.list and do not list all assignment types (curation.reference-intake is not a research assignment). To create research work, use Assignment.create{ type = \"research\", title = \"...\", apply = true }."
|
|
2194
2194
|
})];
|
|
2195
2195
|
if !static_prompt.publication_mission.trim().is_empty() {
|
|
2196
2196
|
messages.push(json!({
|
|
@@ -3,7 +3,7 @@ import { getSiteRenderer } from "../../../../../lib/site-renderer";
|
|
|
3
3
|
import { getCachedEditionItem, loadCachedEditionContent } from "../../../../../lib/cached-content-repository";
|
|
4
4
|
import { getEditionDatePath, parseEditionArticleRoute } from "../../../../../lib/edition-routes";
|
|
5
5
|
import { buildPresentationFooterEntries } from "../../../../../lib/presentation-footer";
|
|
6
|
-
import { SITE_BRAND } from "../../../../../lib/site-brand";
|
|
6
|
+
import { SITE_BRAND, editionsEnabled } from "../../../../../lib/site-brand";
|
|
7
7
|
import { generateEditionItemStaticParams } from "../../../../../lib/reader-static-params";
|
|
8
8
|
// Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
|
|
9
9
|
export const revalidate = 60;
|
|
@@ -39,6 +39,7 @@ export async function generateMetadata({ params }: DateScopedArticlePageProps) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export default async function DateScopedArticlePage({ params }: DateScopedArticlePageProps) {
|
|
42
|
+
if (!editionsEnabled()) notFound();
|
|
42
43
|
const { year, month, day, articleSlug } = await params;
|
|
43
44
|
const route = parseEditionArticleRoute({ year, month, day, articleSlug });
|
|
44
45
|
if (!route) notFound();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { notFound, redirect } from "next/navigation";
|
|
2
|
+
import { editionsEnabled } from "../../../../lib/site-brand";
|
|
2
3
|
import { siteServesNextReaderRoutes } from "../../../../lib/reader-static-params";
|
|
3
4
|
import { getSiteRenderer } from "../../../../lib/site-renderer";
|
|
4
5
|
import type { PresentationTarget } from "../../../../renderers/pretext";
|
|
@@ -23,7 +24,7 @@ export async function EditionRoutePage({
|
|
|
23
24
|
lockedPresentation,
|
|
24
25
|
target = { kind: "edition" },
|
|
25
26
|
}: EditionRoutePageProps) {
|
|
26
|
-
if (!siteServesNextReaderRoutes()) notFound();
|
|
27
|
+
if (!editionsEnabled() || !siteServesNextReaderRoutes()) notFound();
|
|
27
28
|
const route = parseEditionDateRoute({ year, month, day });
|
|
28
29
|
if (!route) notFound();
|
|
29
30
|
if (!route.isCanonical) redirect(route.canonicalPath);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { notFound, redirect } from "next/navigation";
|
|
2
2
|
import { EditionRoutePage } from "../../edition-route-page";
|
|
3
3
|
import { parseEditionPageRoute } from "../../../../../../lib/edition-routes";
|
|
4
|
-
import { getDefaultPretextLayout, getForcedPresentation } from "../../../../../../lib/site-brand";
|
|
4
|
+
import { editionsEnabled, getDefaultPretextLayout, getForcedPresentation } from "../../../../../../lib/site-brand";
|
|
5
5
|
import { generateEditionPageStaticParams } from "../../../../../../lib/reader-static-params";
|
|
6
6
|
// Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
|
|
7
7
|
export const revalidate = 60;
|
|
@@ -20,6 +20,7 @@ type EditionPageRouteProps = {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
export default async function EditionPageRoute({ params }: EditionPageRouteProps) {
|
|
23
|
+
if (!editionsEnabled()) notFound();
|
|
23
24
|
const { year, month, day, pageNumber } = await params;
|
|
24
25
|
const route = parseEditionPageRoute({ year, month, day, pageNumber });
|
|
25
26
|
if (!route) notFound();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { notFound } from "next/navigation";
|
|
2
|
+
import { editionsEnabled } from "../../../../lib/site-brand";
|
|
1
3
|
import { EditionRoutePage } from "./edition-route-page";
|
|
2
4
|
import { generateEditionDateStaticParams } from "../../../../lib/reader-static-params";
|
|
3
5
|
|
|
@@ -17,6 +19,7 @@ type DateEditionPageProps = {
|
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export default async function DateEditionPage({ params }: DateEditionPageProps) {
|
|
22
|
+
if (!editionsEnabled()) notFound();
|
|
20
23
|
const { year, month, day } = await params;
|
|
21
24
|
return <EditionRoutePage day={day} month={month} year={year} />;
|
|
22
25
|
}
|
|
@@ -2,6 +2,7 @@ import { notFound, redirect } from "next/navigation";
|
|
|
2
2
|
import { EditionRoutePage } from "../../edition-route-page";
|
|
3
3
|
import { createSectionKey } from "../../../../../../lib/edition-sections";
|
|
4
4
|
import { parseEditionSectionRoute } from "../../../../../../lib/edition-routes";
|
|
5
|
+
import { editionsEnabled } from "../../../../../../lib/site-brand";
|
|
5
6
|
import { generateEditionSectionStaticParams } from "../../../../../../lib/reader-static-params";
|
|
6
7
|
// Keep in sync with READER_REVALIDATE_SECONDS in lib/reader-route-config.ts
|
|
7
8
|
export const revalidate = 60;
|
|
@@ -20,6 +21,7 @@ type EditionSectionRouteProps = {
|
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
export default async function EditionSectionRoute({ params }: EditionSectionRouteProps) {
|
|
24
|
+
if (!editionsEnabled()) notFound();
|
|
23
25
|
const { year, month, day, sectionKey } = await params;
|
|
24
26
|
const normalizedSectionKey = createSectionKey(sectionKey);
|
|
25
27
|
const route = parseEditionSectionRoute({ year, month, day, sectionKey: normalizedSectionKey });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
import { editionsEnabled } from "../../../../lib/site-brand";
|
|
2
3
|
import { loadArchiveEditionPreviews, clampArchiveLimit } from "../../../../lib/archive-data";
|
|
3
4
|
import { ARCHIVE_BATCH_SIZE } from "../../../../lib/archive-types";
|
|
4
5
|
|
|
@@ -6,6 +7,7 @@ import { ARCHIVE_BATCH_SIZE } from "../../../../lib/archive-types";
|
|
|
6
7
|
export const revalidate = 60;
|
|
7
8
|
|
|
8
9
|
export async function GET(request: Request) {
|
|
10
|
+
if (!editionsEnabled()) return new NextResponse(null, { status: 404 });
|
|
9
11
|
const url = new URL(request.url);
|
|
10
12
|
const cursor = url.searchParams.get("cursor");
|
|
11
13
|
const requestedLimit = Number(url.searchParams.get("limit") ?? ARCHIVE_BATCH_SIZE);
|
package/app/archive/page.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { notFound } from "next/navigation";
|
|
2
|
+
import { editionsEnabled } from "../../lib/site-brand";
|
|
1
3
|
import { ArchiveShell } from "../../components/archive-shell";
|
|
2
4
|
import { ArchiveGrid } from "../../components/archive-grid";
|
|
3
5
|
import { loadArchiveEditionPreviews } from "../../lib/archive-data";
|
|
@@ -7,6 +9,7 @@ import { ARCHIVE_BATCH_SIZE } from "../../lib/archive-types";
|
|
|
7
9
|
export const revalidate = 60;
|
|
8
10
|
|
|
9
11
|
export default async function ArchivePage() {
|
|
12
|
+
if (!editionsEnabled()) notFound();
|
|
10
13
|
const initialBatch = await loadArchiveEditionPreviews({ limit: ARCHIVE_BATCH_SIZE });
|
|
11
14
|
|
|
12
15
|
return (
|
package/app/page.tsx
CHANGED
|
@@ -15,7 +15,10 @@ import {
|
|
|
15
15
|
type PublicPlaceholderTopic,
|
|
16
16
|
} from "../lib/public-placeholder-config";
|
|
17
17
|
import type { PublicationItem } from "../lib/publication-items";
|
|
18
|
-
import { SITE_BRAND, rootRoute } from "../lib/site-brand";
|
|
18
|
+
import { SITE_BRAND, editionsEnabled, rootRoute } from "../lib/site-brand";
|
|
19
|
+
import { ItemStreamView } from "../components/item-stream-view";
|
|
20
|
+
import { ITEM_STREAM_RECENT_EDITION_COUNT, buildItemStream, selectNewestStreamItems } from "../lib/item-stream";
|
|
21
|
+
import { listCachedPublishedEditions } from "../lib/cached-content-repository";
|
|
19
22
|
import { notFound, redirect } from "next/navigation";
|
|
20
23
|
import { getReaderBasePath } from "../lib/reader-base-path";
|
|
21
24
|
import { buildRootRedirectTarget } from "../lib/root-route-redirect";
|
|
@@ -27,6 +30,7 @@ export const dynamic = "force-dynamic";
|
|
|
27
30
|
type HomePageProps = {
|
|
28
31
|
searchParams?: Promise<{
|
|
29
32
|
scenario?: string | string[];
|
|
33
|
+
section?: string | string[];
|
|
30
34
|
code?: string | string[];
|
|
31
35
|
state?: string | string[];
|
|
32
36
|
error?: string | string[];
|
|
@@ -59,6 +63,13 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|
|
59
63
|
notFound();
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
if (!editionsEnabled()) {
|
|
67
|
+
const sectionParam = resolvedSearchParams?.section;
|
|
68
|
+
const sectionKey = Array.isArray(sectionParam) ? sectionParam[0] : sectionParam;
|
|
69
|
+
const items = await loadNewestStreamItemsOrNone();
|
|
70
|
+
return <ItemStreamView stream={buildItemStream(items, sectionKey)} />;
|
|
71
|
+
}
|
|
72
|
+
|
|
62
73
|
// Resolved only on the reader path: Markus brands (renderer "markus") have
|
|
63
74
|
// no Next.js reader and getSiteRenderer() throws for them, but they can
|
|
64
75
|
// still serve the newsroom at the root via rootRoute above.
|
|
@@ -88,6 +99,24 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|
|
88
99
|
return <siteRenderer.renderEdition content={content} />;
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
async function loadNewestStreamItemsOrNone() {
|
|
103
|
+
try {
|
|
104
|
+
return await loadNewestStreamItems();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (!isMissingReaderBackendError(error)) throw error;
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function loadNewestStreamItems() {
|
|
112
|
+
const { editions } = await listCachedPublishedEditions({ limit: ITEM_STREAM_RECENT_EDITION_COUNT });
|
|
113
|
+
const newestFirst = [...editions].sort((left, right) => right.editionDate.localeCompare(left.editionDate));
|
|
114
|
+
const contents = await Promise.all(
|
|
115
|
+
newestFirst.map((edition) => loadCachedEditionContent({ editionDate: edition.editionDate })),
|
|
116
|
+
);
|
|
117
|
+
return selectNewestStreamItems(contents);
|
|
118
|
+
}
|
|
119
|
+
|
|
91
120
|
async function loadLatestGraphQLEdition(): Promise<EditionContent | null> {
|
|
92
121
|
const latestEdition = await getCachedLatestPublishedEdition();
|
|
93
122
|
if (!latestEdition) return null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import type { ItemStream } from "../lib/item-stream";
|
|
3
|
+
import { toPublicReaderPath } from "../lib/reader-base-path";
|
|
4
|
+
import { SITE_BRAND } from "../lib/site-brand";
|
|
5
|
+
|
|
6
|
+
/** The home of a publication without editions: a masthead, section filters and the newest items. */
|
|
7
|
+
export function ItemStreamView({ stream }: { stream: ItemStream }) {
|
|
8
|
+
const homePath = toPublicReaderPath("/");
|
|
9
|
+
const tagline = SITE_BRAND.mastheadTagline ?? null;
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<main className="presentation-page presentation-page--blog" data-presentation-engine="item-stream">
|
|
13
|
+
<header className="presentation-header">
|
|
14
|
+
<div className="presentation-header__copy-stack">
|
|
15
|
+
<h1>
|
|
16
|
+
<Link href={homePath}>{SITE_BRAND.mastheadTitle}</Link>
|
|
17
|
+
</h1>
|
|
18
|
+
<div className="presentation-header__meta">
|
|
19
|
+
<span className="presentation-header__subtitle">{SITE_BRAND.mastheadSubtitle}</span>
|
|
20
|
+
</div>
|
|
21
|
+
{tagline ? <span className="presentation-header__tagline">{tagline}</span> : null}
|
|
22
|
+
</div>
|
|
23
|
+
</header>
|
|
24
|
+
{stream.sections.length > 1 ? (
|
|
25
|
+
<nav className="presentation-section-nav" aria-label="Sections">
|
|
26
|
+
<Link href={homePath} aria-current={stream.activeSectionKey === null ? "page" : undefined}>
|
|
27
|
+
All
|
|
28
|
+
</Link>
|
|
29
|
+
{stream.sections.map((section) => (
|
|
30
|
+
<Link
|
|
31
|
+
aria-current={stream.activeSectionKey === section.key ? "page" : undefined}
|
|
32
|
+
href={`${homePath}?section=${encodeURIComponent(section.key)}`}
|
|
33
|
+
key={section.key}
|
|
34
|
+
>
|
|
35
|
+
{section.label}
|
|
36
|
+
</Link>
|
|
37
|
+
))}
|
|
38
|
+
</nav>
|
|
39
|
+
) : null}
|
|
40
|
+
{stream.items.length === 0 ? (
|
|
41
|
+
<section className="item-stream-empty" data-item-stream-empty="true">
|
|
42
|
+
<p>Nothing published yet</p>
|
|
43
|
+
</section>
|
|
44
|
+
) : (
|
|
45
|
+
<div className="blog-sections">
|
|
46
|
+
{stream.items.map((item) => (
|
|
47
|
+
<article className="presentation-item presentation-item--blog" data-item-id={item.slug} key={item.slug}>
|
|
48
|
+
<div className="presentation-item__copy">
|
|
49
|
+
<header className="presentation-item__header">
|
|
50
|
+
<p>{item.section ?? "General"}</p>
|
|
51
|
+
<h2>
|
|
52
|
+
<Link href={toPublicReaderPath(`/articles/${encodeURIComponent(item.slug)}`)}>{item.headline}</Link>
|
|
53
|
+
</h2>
|
|
54
|
+
{item.deck ? <span>{item.deck}</span> : null}
|
|
55
|
+
</header>
|
|
56
|
+
</div>
|
|
57
|
+
</article>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
</main>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -117,7 +117,7 @@ function AssignmentDetailPanel({
|
|
|
117
117
|
detailExtras?: ReactNode;
|
|
118
118
|
}) {
|
|
119
119
|
const terminal = assignment.status === "completed" || assignment.status === "canceled";
|
|
120
|
-
const isReporting = assignment.assignmentTypeKey === "reporting.
|
|
120
|
+
const isReporting = assignment.assignmentTypeKey === "reporting.candidate";
|
|
121
121
|
|
|
122
122
|
return (
|
|
123
123
|
<div
|
|
@@ -12368,7 +12368,7 @@ function ReportingStoryBudgetBoard({
|
|
|
12368
12368
|
section={section}
|
|
12369
12369
|
selectedAssignmentId={selectedAssignmentId}
|
|
12370
12370
|
/>
|
|
12371
|
-
)) : <EmptyRow label="No reporting
|
|
12371
|
+
)) : <EmptyRow label="No reporting candidate assignments found" />}
|
|
12372
12372
|
</div>
|
|
12373
12373
|
);
|
|
12374
12374
|
}
|
|
@@ -14568,7 +14568,7 @@ function buildUiReportingPacketReviewPlan({
|
|
|
14568
14568
|
event: AssignmentEventRecord;
|
|
14569
14569
|
relations: SemanticRelationRecord[];
|
|
14570
14570
|
} {
|
|
14571
|
-
if (assignment.assignmentTypeKey !== "reporting.
|
|
14571
|
+
if (assignment.assignmentTypeKey !== "reporting.candidate") throw new Error("Only reporting candidate assignments can review reporting packets.");
|
|
14572
14572
|
if (message.messageKind !== "reporting_context_packet") throw new Error("Only reporting context packets can be reviewed.");
|
|
14573
14573
|
if (decision === "merge" && !targetItem?.id && !targetItemId.trim()) throw new Error("Merge Packet requires a target Item ID.");
|
|
14574
14574
|
const resolvedTargetItem = targetItem ?? (targetItemId.trim() ? { id: targetItemId.trim(), lineageId: targetItemId.trim(), versionNumber: null } : null);
|
|
@@ -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
|
-
*
|
|
47
|
-
* until Amplify's GitHub App is
|
|
48
|
-
* (docs/site-hosting.md).
|
|
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
|
-
*
|
|
50
|
-
* until Amplify's GitHub App is
|
|
51
|
-
* (docs/site-hosting.md).
|
|
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",
|
package/infra/site-config.js
CHANGED
|
@@ -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,
|
package/infra/site-config.ts
CHANGED
|
@@ -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,
|
|
@@ -1510,7 +1510,7 @@ export function createDemoCategorySteeringDashboard(
|
|
|
1510
1510
|
assignments: [
|
|
1511
1511
|
{
|
|
1512
1512
|
id: assignmentReportingId,
|
|
1513
|
-
assignmentTypeKey: "reporting.
|
|
1513
|
+
assignmentTypeKey: "reporting.candidate",
|
|
1514
1514
|
queueKey: "edition:edition-demo:section:news:lane:reporting",
|
|
1515
1515
|
queueStatusKey: "edition:edition-demo:section:news:lane:reporting#open",
|
|
1516
1516
|
status: "open",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { EditionContent, EditionSection } from "./content-types";
|
|
2
|
+
import { createEditionSectionPlan, createSectionKey } from "./edition-sections";
|
|
3
|
+
import { getArticlePublicationItems, type ArticlePublicationItem } from "./publication-items";
|
|
4
|
+
|
|
5
|
+
export const ITEM_STREAM_RECENT_EDITION_COUNT = 10;
|
|
6
|
+
export const ITEM_STREAM_ITEM_LIMIT = 50;
|
|
7
|
+
|
|
8
|
+
export type ItemStream = {
|
|
9
|
+
items: ArticlePublicationItem[];
|
|
10
|
+
sections: EditionSection[];
|
|
11
|
+
activeSectionKey: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Flattens published content into one newest-first stream of articles. The
|
|
16
|
+
* input is ordered newest first; a slug that appears twice keeps its newest entry.
|
|
17
|
+
*/
|
|
18
|
+
export function selectNewestStreamItems(
|
|
19
|
+
newestFirstContents: EditionContent[],
|
|
20
|
+
limit: number = ITEM_STREAM_ITEM_LIMIT,
|
|
21
|
+
): ArticlePublicationItem[] {
|
|
22
|
+
const seenSlugs = new Set<string>();
|
|
23
|
+
const selected: ArticlePublicationItem[] = [];
|
|
24
|
+
for (const content of newestFirstContents) {
|
|
25
|
+
for (const item of getArticlePublicationItems(content.items)) {
|
|
26
|
+
if (seenSlugs.has(item.slug)) continue;
|
|
27
|
+
seenSlugs.add(item.slug);
|
|
28
|
+
selected.push(item);
|
|
29
|
+
if (selected.length >= limit) return selected;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return selected;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Builds the stream view: the sections found in the items, and the items filtered by an optional section key. */
|
|
36
|
+
export function buildItemStream(items: ArticlePublicationItem[], sectionKey?: string | null): ItemStream {
|
|
37
|
+
const sections = createEditionSectionPlan(items);
|
|
38
|
+
const activeSectionKey = sectionKey ? createSectionKey(sectionKey) : null;
|
|
39
|
+
const activeSection = activeSectionKey ? sections.find((section) => section.key === activeSectionKey) : undefined;
|
|
40
|
+
if (!activeSection) return { items, sections, activeSectionKey: null };
|
|
41
|
+
const itemIds = new Set(activeSection.itemIds);
|
|
42
|
+
return {
|
|
43
|
+
items: items.filter((item) => itemIds.has(item.slug)),
|
|
44
|
+
sections,
|
|
45
|
+
activeSectionKey: activeSection.key,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -41,7 +41,7 @@ export function formatAssignmentTypeLabel(typeKey: string | null | undefined): s
|
|
|
41
41
|
if (key === "analysis.reindex") return "Analysis re-index";
|
|
42
42
|
if (key === "newsroom.research" || key === "research") return "Research";
|
|
43
43
|
if (key === "curation.reference-intake") return "Reference intake";
|
|
44
|
-
if (key === "reporting.
|
|
44
|
+
if (key === "reporting.candidate") return "Reporting candidate";
|
|
45
45
|
return key
|
|
46
46
|
.split(/[.:_-]+/)
|
|
47
47
|
.filter(Boolean)
|
|
@@ -194,7 +194,7 @@ export function applyPilobolusDemoDashboardContent(
|
|
|
194
194
|
});
|
|
195
195
|
|
|
196
196
|
localized.assignments = localized.assignments.map((assignment, index) => {
|
|
197
|
-
if (assignment.assignmentTypeKey === "reporting.
|
|
197
|
+
if (assignment.assignmentTypeKey === "reporting.candidate") {
|
|
198
198
|
return {
|
|
199
199
|
...assignment,
|
|
200
200
|
title: "Field-notes reporting candidate 1: Strangler-fig control-loop shift",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ContentRepository, EditionContent } from "./content-types";
|
|
2
2
|
import { graphqlContentRepository } from "./graphql-content-repository";
|
|
3
|
-
import { SITE_BRAND } from "./site-brand";
|
|
3
|
+
import { SITE_BRAND, editionsEnabled } from "./site-brand";
|
|
4
4
|
import { getContentSource } from "./site-env";
|
|
5
5
|
import type { RendererConfig } from "./renderer-config";
|
|
6
6
|
|
|
@@ -40,6 +40,7 @@ const RESERVED_DATE_CHILD_SEGMENTS = new Set(["page", "section"]);
|
|
|
40
40
|
type EnumerationRepository = Pick<ContentRepository, "listPublishedEditions" | "loadEditionContent">;
|
|
41
41
|
|
|
42
42
|
function shouldEnumerateAtBuild(): boolean {
|
|
43
|
+
if (!editionsEnabled()) return false;
|
|
43
44
|
if (process.env.NODE_ENV === "development") return false;
|
|
44
45
|
return siteEnumeratesReaderRoutesAtBuild();
|
|
45
46
|
}
|
|
@@ -106,7 +106,7 @@ export type ReportingStoryBudgetInput = {
|
|
|
106
106
|
messagePayloads?: Record<string, unknown>;
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
-
const REPORTING_ASSIGNMENT_TYPE = "reporting.
|
|
109
|
+
const REPORTING_ASSIGNMENT_TYPE = "reporting.candidate";
|
|
110
110
|
const REPORTING_PACKET_KIND = "reporting_context_packet";
|
|
111
111
|
const RESEARCH_PACKET_KIND = "research_packet";
|
|
112
112
|
|
|
@@ -331,7 +331,7 @@ function buildResearchPacketIdsByReportingAssignment(assignments: AssignmentReco
|
|
|
331
331
|
if (relation.subjectKind === "assignment" && relation.objectKind === "assignment") {
|
|
332
332
|
const reporting = assignmentById.get(relation.subjectId);
|
|
333
333
|
const research = assignmentById.get(relation.objectId);
|
|
334
|
-
if (reporting?.assignmentTypeKey !== REPORTING_ASSIGNMENT_TYPE || research?.assignmentTypeKey !== "research.
|
|
334
|
+
if (reporting?.assignmentTypeKey !== REPORTING_ASSIGNMENT_TYPE || research?.assignmentTypeKey !== "research.candidate") continue;
|
|
335
335
|
for (const packetId of producedResearchPacketsByAssignment.get(research.id) ?? []) {
|
|
336
336
|
result.set(reporting.id, addToSet(result.get(reporting.id), packetId));
|
|
337
337
|
}
|
package/lib/site-brand.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isLocalPreviewEnabled } from "./local-preview";
|
|
2
|
+
import { areEditionsEnabled } from "./site-env";
|
|
2
3
|
import type { ComponentType } from "react";
|
|
3
4
|
import type { EditionPresentationFormat } from "./content-types";
|
|
4
5
|
import type { Article, ArticleVideoAsset } from "./articles";
|
|
@@ -157,6 +158,11 @@ export type SiteBrand = {
|
|
|
157
158
|
fonts?: SiteBrandFont[];
|
|
158
159
|
demoEdition?: SiteBrandDemoEdition;
|
|
159
160
|
video?: SiteBrandVideo;
|
|
161
|
+
/**
|
|
162
|
+
* Editions are an optional setting, off unless `{ enabled: true }`. When off the
|
|
163
|
+
* publication is a plain stream of items: no edition date routes, archive or masthead date.
|
|
164
|
+
*/
|
|
165
|
+
editions?: { enabled: boolean };
|
|
160
166
|
};
|
|
161
167
|
|
|
162
168
|
const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
|
|
@@ -253,6 +259,11 @@ export function getRootRoute(brand: SiteBrand = SITE_BRAND): RootRouteConfig {
|
|
|
253
259
|
|
|
254
260
|
export const rootRoute = getRootRoute();
|
|
255
261
|
|
|
262
|
+
/** The brand setting wins when present; otherwise the PAPYRUS_EDITIONS_ENABLED switch decides. */
|
|
263
|
+
export function editionsEnabled(brand: Pick<SiteBrand, "editions"> = SITE_BRAND): boolean {
|
|
264
|
+
return brand.editions?.enabled ?? areEditionsEnabled();
|
|
265
|
+
}
|
|
266
|
+
|
|
256
267
|
export function enforcePresentation(presentation: EditionPresentationFormat): EditionPresentationFormat {
|
|
257
268
|
return SITE_BRAND.forcedPresentation ?? presentation;
|
|
258
269
|
}
|
package/lib/site-env.ts
CHANGED
|
@@ -20,6 +20,11 @@ export function getSiteEnv(environment: EnvironmentVariables = process.env): Sit
|
|
|
20
20
|
return configured as SiteEnv;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export function areEditionsEnabled(environment: EnvironmentVariables = process.env): boolean {
|
|
24
|
+
const configured = (environment.PAPYRUS_EDITIONS_ENABLED ?? "").trim().toLowerCase();
|
|
25
|
+
return configured === "true" || configured === "1" || configured === "yes" || configured === "on";
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
export function getContentSource(environment: EnvironmentVariables = process.env): ContentSource {
|
|
24
29
|
const configured = (environment.PAPYRUS_CONTENT_SOURCE ?? "").trim();
|
|
25
30
|
if (!configured) return "published";
|
package/package.json
CHANGED
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
"./components/blog-page-background": "./components/blog-page-background.tsx",
|
|
79
79
|
"./components/curation-delegation-client": "./components/curation-delegation-client.ts",
|
|
80
80
|
"./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
|
|
81
|
+
"./components/item-stream-view": "./components/item-stream-view.tsx",
|
|
81
82
|
"./components/markus-ir-preview": "./components/markus-ir-preview.tsx",
|
|
82
83
|
"./components/news-desk-client-provider": "./components/news-desk-client-provider.tsx",
|
|
83
84
|
"./components/news-desk-page": "./components/news-desk-page.tsx",
|
|
@@ -191,6 +192,7 @@
|
|
|
191
192
|
"./lib/furniture": "./lib/furniture.ts",
|
|
192
193
|
"./lib/graphql-content-repository": "./lib/graphql-content-repository.ts",
|
|
193
194
|
"./lib/image-url": "./lib/image-url.ts",
|
|
195
|
+
"./lib/item-stream": "./lib/item-stream.ts",
|
|
194
196
|
"./lib/labels": "./lib/labels.ts",
|
|
195
197
|
"./lib/layout-config": "./lib/layout-config.ts",
|
|
196
198
|
"./lib/layout-plan": "./lib/layout-plan.ts",
|
|
@@ -377,7 +379,7 @@
|
|
|
377
379
|
"optional": true
|
|
378
380
|
}
|
|
379
381
|
},
|
|
380
|
-
"version": "1.
|
|
382
|
+
"version": "1.17.0",
|
|
381
383
|
"dependencies": {
|
|
382
384
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
383
385
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED