@anthusai/papyrus 1.0.0-next.5 → 1.0.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.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/content-actions/handler.py +44 -2
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/site-backend-config.ts +6 -0
- package/amplify/site-backend.js +18 -0
- package/amplify/site-backend.ts +20 -0
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/components/markus-ir-preview.tsx +279 -0
- package/components/newsroom-access-gate.tsx +66 -0
- package/components/newsroom-article-editor.tsx +637 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/topic-steering-workspace.tsx +1 -59
- package/lib/content-actions-demo.ts +172 -0
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-nav.ts +2 -0
- package/package.json +10 -1
- package/routes.manifest.json +17 -1
package/amplify/data/resource.js
CHANGED
|
@@ -493,6 +493,12 @@ const schema = a.schema({
|
|
|
493
493
|
.returns(a.json())
|
|
494
494
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
495
495
|
.handler(a.handler.function(contentActions)),
|
|
496
|
+
requestStagingBuild: a
|
|
497
|
+
.mutation()
|
|
498
|
+
.arguments({ input: a.json().required() })
|
|
499
|
+
.returns(a.json())
|
|
500
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
501
|
+
.handler(a.handler.function(contentActions)),
|
|
496
502
|
knowledgeQuery: a
|
|
497
503
|
.query()
|
|
498
504
|
.arguments({
|
package/amplify/data/resource.ts
CHANGED
|
@@ -537,6 +537,13 @@ const schema = a.schema({
|
|
|
537
537
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
538
538
|
.handler(a.handler.function(contentActions)),
|
|
539
539
|
|
|
540
|
+
requestStagingBuild: a
|
|
541
|
+
.mutation()
|
|
542
|
+
.arguments({ input: a.json().required() })
|
|
543
|
+
.returns(a.json())
|
|
544
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
545
|
+
.handler(a.handler.function(contentActions)),
|
|
546
|
+
|
|
540
547
|
knowledgeQuery: a
|
|
541
548
|
.query()
|
|
542
549
|
.arguments({
|
package/amplify/data/schema.js
CHANGED
|
@@ -393,6 +393,12 @@ export const schema = a.schema({
|
|
|
393
393
|
.returns(a.json())
|
|
394
394
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
395
395
|
.handler(a.handler.function(contentActions)),
|
|
396
|
+
requestStagingBuild: a
|
|
397
|
+
.mutation()
|
|
398
|
+
.arguments({ input: a.json().required() })
|
|
399
|
+
.returns(a.json())
|
|
400
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
401
|
+
.handler(a.handler.function(contentActions)),
|
|
396
402
|
knowledgeQuery: a
|
|
397
403
|
.query()
|
|
398
404
|
.arguments({
|
package/amplify/data/schema.ts
CHANGED
|
@@ -431,6 +431,13 @@ export const schema = a.schema({
|
|
|
431
431
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
432
432
|
.handler(a.handler.function(contentActions)),
|
|
433
433
|
|
|
434
|
+
requestStagingBuild: a
|
|
435
|
+
.mutation()
|
|
436
|
+
.arguments({ input: a.json().required() })
|
|
437
|
+
.returns(a.json())
|
|
438
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
439
|
+
.handler(a.handler.function(contentActions)),
|
|
440
|
+
|
|
434
441
|
knowledgeQuery: a
|
|
435
442
|
.query()
|
|
436
443
|
.arguments({
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
6
7
|
from papyrus_content.graphql_authoring import create_authoring_client
|
|
7
8
|
from papyrus_content.markus_renderer.derive import derive_body
|
|
9
|
+
from papyrus_content.rebuild_trigger import (
|
|
10
|
+
DEFAULT_STAGING_BRANCH,
|
|
11
|
+
reader_target_from_environment,
|
|
12
|
+
trigger_pretext_revalidation,
|
|
13
|
+
trigger_rebuild,
|
|
14
|
+
trigger_staging_build,
|
|
15
|
+
)
|
|
8
16
|
from papyrus_content.publishing import (
|
|
9
17
|
ItemFields,
|
|
10
18
|
PublishError,
|
|
@@ -72,21 +80,53 @@ def save_item_draft_action(arguments: dict, actor: str) -> dict:
|
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
|
|
83
|
+
def site_triggers_for(client, item_id: str) -> dict:
|
|
84
|
+
triggers: dict = {}
|
|
85
|
+
reader_app_id, reader_branch = reader_target_from_environment()
|
|
86
|
+
revalidate_base_url = (os.environ.get("PAPYRUS_REVALIDATE_BASE_URL") or "").strip()
|
|
87
|
+
if revalidate_base_url:
|
|
88
|
+
try:
|
|
89
|
+
item = client.get_record("Item", item_id) or {}
|
|
90
|
+
slugs = [item["slug"]] if item.get("slug") else []
|
|
91
|
+
revalidated = trigger_pretext_revalidation(slugs, None, base_url=revalidate_base_url)
|
|
92
|
+
triggers["revalidated"] = revalidated if revalidated is not None else {"ok": False, "reason": "not-configured"}
|
|
93
|
+
except Exception as error: # noqa: BLE001 - a trigger failure never fails the publish
|
|
94
|
+
triggers["revalidated"] = {"ok": False, "error": f"{type(error).__name__}: {error}"}
|
|
95
|
+
if reader_app_id:
|
|
96
|
+
triggers["rebuild"] = trigger_rebuild(reader_app_id=reader_app_id, reader_branch=reader_branch)
|
|
97
|
+
return triggers
|
|
98
|
+
|
|
99
|
+
|
|
75
100
|
def publish_item_action(arguments: dict, actor: str) -> dict:
|
|
76
101
|
client, _claims = create_authoring_client()
|
|
77
102
|
result = publish_item(client, arguments["id"], actor=actor)
|
|
78
|
-
|
|
103
|
+
response = {
|
|
79
104
|
"ok": True,
|
|
80
105
|
"changed": result.changed,
|
|
81
106
|
"publishedId": result.published_item_id,
|
|
82
107
|
"versionNumber": result.version_number,
|
|
83
108
|
}
|
|
109
|
+
if result.changed:
|
|
110
|
+
response.update(site_triggers_for(client, arguments["id"]))
|
|
111
|
+
return response
|
|
84
112
|
|
|
85
113
|
|
|
86
114
|
def unpublish_item_action(arguments: dict, actor: str) -> dict:
|
|
87
115
|
client, _claims = create_authoring_client()
|
|
88
116
|
result = unpublish_item(client, arguments["id"], actor=actor)
|
|
89
|
-
|
|
117
|
+
response = {"ok": True, "changed": result.changed}
|
|
118
|
+
if result.changed:
|
|
119
|
+
response.update(site_triggers_for(client, arguments["id"]))
|
|
120
|
+
return response
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def request_staging_build_action() -> dict:
|
|
124
|
+
cms_app_id = (os.environ.get("PAPYRUS_CMS_AMPLIFY_APP_ID") or "").strip()
|
|
125
|
+
if not cms_app_id:
|
|
126
|
+
return {"ok": False, "errors": [{"code": "staging-build-not-configured", "message": "This site has no staging build configured.", "line": None}]}
|
|
127
|
+
staging_branch = (os.environ.get("PAPYRUS_STAGING_BRANCH") or "").strip() or DEFAULT_STAGING_BRANCH
|
|
128
|
+
build = trigger_staging_build(cms_app_id=cms_app_id, staging_branch=staging_branch)
|
|
129
|
+
return {"ok": True, "build": build}
|
|
90
130
|
|
|
91
131
|
|
|
92
132
|
def handler(event, context):
|
|
@@ -102,6 +142,8 @@ def handler(event, context):
|
|
|
102
142
|
return publish_item_action(arguments, actor)
|
|
103
143
|
if field == "unpublishItem":
|
|
104
144
|
return unpublish_item_action(arguments, actor)
|
|
145
|
+
if field == "requestStagingBuild":
|
|
146
|
+
return request_staging_build_action()
|
|
105
147
|
except PublishError as error:
|
|
106
148
|
return failure_response(error)
|
|
107
149
|
raise ValueError(f"Unsupported field {field}")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const REBUILD_TRIGGER_ACTIONS = ["amplify:StartJob", "amplify:ListJobs"];
|
|
2
|
+
function branchJobsArn(input, appId, branch) {
|
|
3
|
+
return `arn:aws:amplify:${input.region}:${input.account}:apps/${appId}/branches/${branch}/jobs/*`;
|
|
4
|
+
}
|
|
5
|
+
export function rebuildTriggerSettings(input) {
|
|
6
|
+
const environment = {};
|
|
7
|
+
const resources = [];
|
|
8
|
+
const readerAppId = (input.reader?.amplifyAppId ?? "").trim();
|
|
9
|
+
if (readerAppId !== "") {
|
|
10
|
+
const readerBranch = (input.reader?.branchName ?? "").trim() || "main";
|
|
11
|
+
environment.PAPYRUS_READER_AMPLIFY_APP_ID = readerAppId;
|
|
12
|
+
environment.PAPYRUS_READER_BRANCH = readerBranch;
|
|
13
|
+
resources.push(branchJobsArn(input, readerAppId, readerBranch));
|
|
14
|
+
}
|
|
15
|
+
const cmsAppId = input.cmsAppId.trim();
|
|
16
|
+
if (input.stagingBuildEnabled && cmsAppId !== "") {
|
|
17
|
+
environment.PAPYRUS_CMS_AMPLIFY_APP_ID = cmsAppId;
|
|
18
|
+
environment.PAPYRUS_STAGING_BRANCH = "staging";
|
|
19
|
+
resources.push(branchJobsArn(input, cmsAppId, "staging"));
|
|
20
|
+
}
|
|
21
|
+
return { environment, actions: REBUILD_TRIGGER_ACTIONS, resources };
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment and IAM policy for the content-actions Lambda's site triggers.
|
|
3
|
+
* Pure (no CDK imports) so the policy can be tested without synthesizing.
|
|
4
|
+
*/
|
|
5
|
+
export type RebuildTriggerInput = {
|
|
6
|
+
reader?: { amplifyAppId: string; branchName?: string };
|
|
7
|
+
stagingBuildEnabled: boolean;
|
|
8
|
+
cmsAppId: string;
|
|
9
|
+
region: string;
|
|
10
|
+
account: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type RebuildTriggerSettings = {
|
|
14
|
+
environment: Record<string, string>;
|
|
15
|
+
actions: string[];
|
|
16
|
+
resources: string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const REBUILD_TRIGGER_ACTIONS = ["amplify:StartJob", "amplify:ListJobs"];
|
|
20
|
+
|
|
21
|
+
function branchJobsArn(input: RebuildTriggerInput, appId: string, branch: string): string {
|
|
22
|
+
return `arn:aws:amplify:${input.region}:${input.account}:apps/${appId}/branches/${branch}/jobs/*`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function rebuildTriggerSettings(input: RebuildTriggerInput): RebuildTriggerSettings {
|
|
26
|
+
const environment: Record<string, string> = {};
|
|
27
|
+
const resources: string[] = [];
|
|
28
|
+
const readerAppId = (input.reader?.amplifyAppId ?? "").trim();
|
|
29
|
+
if (readerAppId !== "") {
|
|
30
|
+
const readerBranch = (input.reader?.branchName ?? "").trim() || "main";
|
|
31
|
+
environment.PAPYRUS_READER_AMPLIFY_APP_ID = readerAppId;
|
|
32
|
+
environment.PAPYRUS_READER_BRANCH = readerBranch;
|
|
33
|
+
resources.push(branchJobsArn(input, readerAppId, readerBranch));
|
|
34
|
+
}
|
|
35
|
+
const cmsAppId = input.cmsAppId.trim();
|
|
36
|
+
if (input.stagingBuildEnabled && cmsAppId !== "") {
|
|
37
|
+
environment.PAPYRUS_CMS_AMPLIFY_APP_ID = cmsAppId;
|
|
38
|
+
environment.PAPYRUS_STAGING_BRANCH = "staging";
|
|
39
|
+
resources.push(branchJobsArn(input, cmsAppId, "staging"));
|
|
40
|
+
}
|
|
41
|
+
return { environment, actions: REBUILD_TRIGGER_ACTIONS, resources };
|
|
42
|
+
}
|
|
@@ -21,6 +21,12 @@ export type PapyrusSiteBackendConfig = {
|
|
|
21
21
|
*/
|
|
22
22
|
productionAppId?: string;
|
|
23
23
|
auth?: PapyrusAuthConfig;
|
|
24
|
+
/** Static reader app (Markus-static sites) that a publish rebuilds through IAM `amplify:StartJob`. */
|
|
25
|
+
reader?: { amplifyAppId: string; branchName?: string };
|
|
26
|
+
/** Lets the newsroom start the `staging` branch build of this CMS app. */
|
|
27
|
+
stagingBuild?: { enabled: boolean };
|
|
28
|
+
/** Base URL of a Pretext reader whose `/api/revalidate` a publish calls (secret `PAPYRUS_REVALIDATE_SECRET`). */
|
|
29
|
+
revalidateBaseUrl?: string;
|
|
24
30
|
features?: {
|
|
25
31
|
consoleResponder?: boolean;
|
|
26
32
|
inboundEmail?: boolean;
|
package/amplify/site-backend.js
CHANGED
|
@@ -12,6 +12,7 @@ import { CfnEventSourceMapping, FunctionUrlAuthType } from "aws-cdk-lib/aws-lamb
|
|
|
12
12
|
import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aws-s3vectors";
|
|
13
13
|
import { dirname, resolve } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
|
|
15
16
|
import { authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
|
|
16
17
|
import { data } from "./data/resource.js";
|
|
17
18
|
import { assignmentAction } from "./functions/assignment-action/resource.js";
|
|
@@ -333,6 +334,23 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
333
334
|
actions: ["appsync:GraphQL"],
|
|
334
335
|
resources: ["*"],
|
|
335
336
|
}));
|
|
337
|
+
const rebuildSettings = rebuildTriggerSettings({
|
|
338
|
+
reader: site.reader,
|
|
339
|
+
stagingBuildEnabled: site.stagingBuild?.enabled === true,
|
|
340
|
+
cmsAppId: amplifyAppId,
|
|
341
|
+
region: Stack.of(contentActionsLambda).region,
|
|
342
|
+
account: Stack.of(contentActionsLambda).account,
|
|
343
|
+
});
|
|
344
|
+
for (const [name, value] of Object.entries(rebuildSettings.environment)) {
|
|
345
|
+
contentActionsLambda.addEnvironment(name, value);
|
|
346
|
+
}
|
|
347
|
+
if (rebuildSettings.resources.length > 0) {
|
|
348
|
+
contentActionsLambda.addToRolePolicy(new PolicyStatement({ actions: rebuildSettings.actions, resources: rebuildSettings.resources }));
|
|
349
|
+
}
|
|
350
|
+
if ((site.revalidateBaseUrl ?? "").trim() !== "") {
|
|
351
|
+
backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_BASE_URL", (site.revalidateBaseUrl ?? "").trim());
|
|
352
|
+
backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET", secret("PAPYRUS_REVALIDATE_SECRET"));
|
|
353
|
+
}
|
|
336
354
|
grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
|
|
337
355
|
grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
|
|
338
356
|
if (backend.sesInboundReceive) {
|
package/amplify/site-backend.ts
CHANGED
|
@@ -13,6 +13,7 @@ import * as s3 from "aws-cdk-lib/aws-s3";
|
|
|
13
13
|
import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aws-s3vectors";
|
|
14
14
|
import { dirname, resolve } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
|
|
16
17
|
import { authConfigFromEnv, defineSiteAuth } from "./auth/resource";
|
|
17
18
|
import { data } from "./data/resource";
|
|
18
19
|
import { assignmentAction } from "./functions/assignment-action/resource";
|
|
@@ -414,6 +415,25 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
414
415
|
resources: ["*"],
|
|
415
416
|
}),
|
|
416
417
|
);
|
|
418
|
+
const rebuildSettings = rebuildTriggerSettings({
|
|
419
|
+
reader: site.reader,
|
|
420
|
+
stagingBuildEnabled: site.stagingBuild?.enabled === true,
|
|
421
|
+
cmsAppId: amplifyAppId,
|
|
422
|
+
region: Stack.of(contentActionsLambda).region,
|
|
423
|
+
account: Stack.of(contentActionsLambda).account,
|
|
424
|
+
});
|
|
425
|
+
for (const [name, value] of Object.entries(rebuildSettings.environment)) {
|
|
426
|
+
contentActionsLambda.addEnvironment(name, value);
|
|
427
|
+
}
|
|
428
|
+
if (rebuildSettings.resources.length > 0) {
|
|
429
|
+
contentActionsLambda.addToRolePolicy(
|
|
430
|
+
new PolicyStatement({ actions: rebuildSettings.actions, resources: rebuildSettings.resources }),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if ((site.revalidateBaseUrl ?? "").trim() !== "") {
|
|
434
|
+
backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_BASE_URL", (site.revalidateBaseUrl ?? "").trim());
|
|
435
|
+
backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET", secret("PAPYRUS_REVALIDATE_SECRET"));
|
|
436
|
+
}
|
|
417
437
|
grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
|
|
418
438
|
grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
|
|
419
439
|
if (backend.sesInboundReceive) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { NewsroomArticleEditor } from "../../../../components/newsroom-article-editor";
|
|
2
|
+
|
|
3
|
+
export const dynamic = "force-dynamic";
|
|
4
|
+
|
|
5
|
+
type NewsroomArticleEditorPageProps = {
|
|
6
|
+
params: Promise<{ id: string }>;
|
|
7
|
+
searchParams?: Promise<{ demo?: string | string[] }>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default async function NewsroomArticleEditorPage({ params, searchParams }: NewsroomArticleEditorPageProps) {
|
|
11
|
+
const { id } = await params;
|
|
12
|
+
const resolved = await searchParams;
|
|
13
|
+
const demoValue = Array.isArray(resolved?.demo) ? resolved?.demo[0] : resolved?.demo;
|
|
14
|
+
return <NewsroomArticleEditor articleId={decodeURIComponent(id)} demo={demoValue === "1"} />;
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NewsroomArticlesView } from "../../../components/newsroom-articles-view";
|
|
2
|
+
|
|
3
|
+
export const dynamic = "force-dynamic";
|
|
4
|
+
|
|
5
|
+
type NewsroomArticlesPageProps = {
|
|
6
|
+
searchParams?: Promise<{ demo?: string | string[] }>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default async function NewsroomArticlesPage({ searchParams }: NewsroomArticlesPageProps) {
|
|
10
|
+
const resolved = await searchParams;
|
|
11
|
+
const demoValue = Array.isArray(resolved?.demo) ? resolved?.demo[0] : resolved?.demo;
|
|
12
|
+
return <NewsroomArticlesView demo={demoValue === "1"} />;
|
|
13
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
parseMarkusDocument,
|
|
4
|
+
type MarkusBlock,
|
|
5
|
+
type MarkusDirective,
|
|
6
|
+
type MarkusInline,
|
|
7
|
+
type MarkusNode,
|
|
8
|
+
} from "../lib/markus-ir";
|
|
9
|
+
|
|
10
|
+
const TOKEN_PATTERN = /PAPYRUSMARKUP\d{5}END/g;
|
|
11
|
+
const WHOLE_TOKEN_PATTERN = /^PAPYRUSMARKUP\d{5}END$/;
|
|
12
|
+
|
|
13
|
+
type PreviewSidecar = {
|
|
14
|
+
images: Record<string, Record<string, unknown>>;
|
|
15
|
+
citations: Record<string, string[]>;
|
|
16
|
+
citationLists: Record<string, unknown>;
|
|
17
|
+
bibliography: string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type PreviewContext = {
|
|
21
|
+
sidecar: PreviewSidecar;
|
|
22
|
+
citationNumbers: Map<string, number>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readSidecar(envelope: Record<string, unknown>): PreviewSidecar {
|
|
30
|
+
const sidecar = envelope.papyrus;
|
|
31
|
+
if (!isRecord(sidecar)) throw new Error("bodyIr.papyrus is missing");
|
|
32
|
+
const { images, citations, citationLists, bibliography } = sidecar;
|
|
33
|
+
if (!isRecord(images) || !isRecord(citations) || !isRecord(citationLists) || !Array.isArray(bibliography)) {
|
|
34
|
+
throw new Error("bodyIr.papyrus must contain images, citations, citationLists and bibliography");
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
images: images as PreviewSidecar["images"],
|
|
38
|
+
citations: citations as PreviewSidecar["citations"],
|
|
39
|
+
citationLists,
|
|
40
|
+
bibliography: bibliography.map(String),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function numberCitationsByFirstAppearance(document: unknown, sidecar: PreviewSidecar): Map<string, number> {
|
|
45
|
+
const numbers = new Map<string, number>();
|
|
46
|
+
for (const token of JSON.stringify(document).match(TOKEN_PATTERN) ?? []) {
|
|
47
|
+
for (const key of sidecar.citations[token] ?? []) {
|
|
48
|
+
if (!numbers.has(key)) numbers.set(key, numbers.size + 1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return numbers;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function imagePlaceholder(token: string, context: PreviewContext): ReactNode {
|
|
55
|
+
const entry = context.sidecar.images[token];
|
|
56
|
+
const src = typeof entry?.src === "string" ? entry.src : "";
|
|
57
|
+
const alt = typeof entry?.alt === "string" ? entry.alt : "";
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className="my-3 rounded-lg border border-dashed border-border bg-muted/30 px-3 py-6 text-center text-xs text-muted-foreground"
|
|
61
|
+
data-markus-preview-image
|
|
62
|
+
>
|
|
63
|
+
{`Image: ${src} (${alt})`}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderText(text: string, context: PreviewContext): ReactNode {
|
|
69
|
+
const parts: ReactNode[] = [];
|
|
70
|
+
let cursor = 0;
|
|
71
|
+
for (const match of text.matchAll(TOKEN_PATTERN)) {
|
|
72
|
+
const token = match[0];
|
|
73
|
+
const index = match.index ?? 0;
|
|
74
|
+
const keys = context.sidecar.citations[token];
|
|
75
|
+
if (!keys) continue;
|
|
76
|
+
if (index > cursor) parts.push(text.slice(cursor, index));
|
|
77
|
+
parts.push(
|
|
78
|
+
<sup className="text-primary" key={`${index}-${token}`}>
|
|
79
|
+
{keys.map((key) => `[${context.citationNumbers.get(key)}]`).join("")}
|
|
80
|
+
</sup>,
|
|
81
|
+
);
|
|
82
|
+
cursor = index + token.length;
|
|
83
|
+
}
|
|
84
|
+
if (cursor < text.length) parts.push(text.slice(cursor));
|
|
85
|
+
return parts;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderInlines(inlines: MarkusInline[], context: PreviewContext): ReactNode[] {
|
|
89
|
+
return inlines.map((node, index) => {
|
|
90
|
+
switch (node.type) {
|
|
91
|
+
case "text":
|
|
92
|
+
return <span key={index}>{renderText(node.text, context)}</span>;
|
|
93
|
+
case "emphasis":
|
|
94
|
+
return <em key={index}>{renderInlines(node.children, context)}</em>;
|
|
95
|
+
case "strong":
|
|
96
|
+
return <strong key={index}>{renderInlines(node.children, context)}</strong>;
|
|
97
|
+
case "strikethrough":
|
|
98
|
+
return <s key={index}>{renderInlines(node.children, context)}</s>;
|
|
99
|
+
case "code_span":
|
|
100
|
+
return <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" key={index}>{node.code}</code>;
|
|
101
|
+
case "link":
|
|
102
|
+
return node.href ? (
|
|
103
|
+
<a className="text-primary underline" href={node.href} key={index} rel="noopener noreferrer" target="_blank">
|
|
104
|
+
{renderInlines(node.children, context)}
|
|
105
|
+
</a>
|
|
106
|
+
) : (
|
|
107
|
+
<span key={index}>{renderInlines(node.children, context)}</span>
|
|
108
|
+
);
|
|
109
|
+
case "image":
|
|
110
|
+
return (
|
|
111
|
+
<span className="rounded border border-dashed border-border px-1 text-xs text-muted-foreground" key={index}>
|
|
112
|
+
{`Image: ${node.src ?? ""} (${node.alt})`}
|
|
113
|
+
</span>
|
|
114
|
+
);
|
|
115
|
+
case "soft_break":
|
|
116
|
+
return <span key={index}>{" "}</span>;
|
|
117
|
+
case "hard_break":
|
|
118
|
+
return <br key={index} />;
|
|
119
|
+
case "html_inline":
|
|
120
|
+
return <code className="font-mono text-xs" key={index}>{node.value}</code>;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function soleToken(inlines: MarkusInline[]): string | null {
|
|
126
|
+
if (inlines.length !== 1 || inlines[0].type !== "text") return null;
|
|
127
|
+
const text = inlines[0].text.trim();
|
|
128
|
+
return WHOLE_TOKEN_PATTERN.test(text) ? text : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const HEADING_CLASSES = [
|
|
132
|
+
"mt-5 mb-2 text-2xl font-semibold tracking-tight",
|
|
133
|
+
"mt-5 mb-2 text-xl font-semibold tracking-tight",
|
|
134
|
+
"mt-4 mb-2 text-lg font-semibold",
|
|
135
|
+
"mt-4 mb-1 text-base font-semibold",
|
|
136
|
+
"mt-3 mb-1 text-sm font-semibold",
|
|
137
|
+
"mt-3 mb-1 text-sm font-medium",
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
function renderBlock(block: MarkusBlock, index: number, context: PreviewContext): ReactNode {
|
|
141
|
+
switch (block.type) {
|
|
142
|
+
case "paragraph": {
|
|
143
|
+
const token = soleToken(block.inline);
|
|
144
|
+
if (token && token in context.sidecar.images) return <div key={index}>{imagePlaceholder(token, context)}</div>;
|
|
145
|
+
if (token && token in context.sidecar.citationLists) {
|
|
146
|
+
return (
|
|
147
|
+
<ol className="my-3 list-decimal space-y-1 pl-6 text-sm" data-markus-preview-bibliography key={index}>
|
|
148
|
+
{context.sidecar.bibliography.map((entry, entryIndex) => (
|
|
149
|
+
<li key={entryIndex}>{entry}</li>
|
|
150
|
+
))}
|
|
151
|
+
</ol>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return <p className="my-3 leading-relaxed" key={index}>{renderInlines(block.inline, context)}</p>;
|
|
155
|
+
}
|
|
156
|
+
case "heading": {
|
|
157
|
+
const level = Math.min(Math.max(block.level, 1), 6);
|
|
158
|
+
const Tag = `h${level}` as "h1";
|
|
159
|
+
return <Tag className={HEADING_CLASSES[level - 1]} key={index}>{renderInlines(block.inline, context)}</Tag>;
|
|
160
|
+
}
|
|
161
|
+
case "list": {
|
|
162
|
+
const items = block.items.map((item, itemIndex) => (
|
|
163
|
+
<li key={itemIndex}>{renderBlocks(item.children, context)}</li>
|
|
164
|
+
));
|
|
165
|
+
return block.ordered ? (
|
|
166
|
+
<ol className="my-3 list-decimal space-y-1 pl-6" key={index} start={block.start ?? undefined}>{items}</ol>
|
|
167
|
+
) : (
|
|
168
|
+
<ul className="my-3 list-disc space-y-1 pl-6" key={index}>{items}</ul>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
case "blockquote":
|
|
172
|
+
return (
|
|
173
|
+
<blockquote className="my-3 border-l-4 border-border pl-4 italic text-muted-foreground" key={index}>
|
|
174
|
+
{renderBlocks(block.children, context)}
|
|
175
|
+
</blockquote>
|
|
176
|
+
);
|
|
177
|
+
case "code":
|
|
178
|
+
return (
|
|
179
|
+
<pre className="my-3 overflow-x-auto rounded-lg bg-muted p-3 font-mono text-xs" key={index}>
|
|
180
|
+
<code>{block.value}</code>
|
|
181
|
+
</pre>
|
|
182
|
+
);
|
|
183
|
+
case "thematic_break":
|
|
184
|
+
return <hr className="my-4 border-border" key={index} />;
|
|
185
|
+
case "table":
|
|
186
|
+
return (
|
|
187
|
+
<div className="my-3 overflow-x-auto" key={index}>
|
|
188
|
+
<table className="w-full border-collapse text-sm">
|
|
189
|
+
<thead>
|
|
190
|
+
<tr>
|
|
191
|
+
{block.header.map((cell, cellIndex) => (
|
|
192
|
+
<th className="border border-border bg-muted/40 px-2 py-1 text-left" key={cellIndex}>
|
|
193
|
+
{renderInlines(cell, context)}
|
|
194
|
+
</th>
|
|
195
|
+
))}
|
|
196
|
+
</tr>
|
|
197
|
+
</thead>
|
|
198
|
+
<tbody>
|
|
199
|
+
{block.rows.map((row, rowIndex) => (
|
|
200
|
+
<tr key={rowIndex}>
|
|
201
|
+
{row.map((cell, cellIndex) => (
|
|
202
|
+
<td className="border border-border px-2 py-1" key={cellIndex}>{renderInlines(cell, context)}</td>
|
|
203
|
+
))}
|
|
204
|
+
</tr>
|
|
205
|
+
))}
|
|
206
|
+
</tbody>
|
|
207
|
+
</table>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
case "html":
|
|
211
|
+
return (
|
|
212
|
+
<pre className="my-3 overflow-x-auto rounded-lg bg-muted p-3 font-mono text-xs" key={index}>{block.value}</pre>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function renderDirective(directive: MarkusDirective, index: number, context: PreviewContext): ReactNode {
|
|
218
|
+
if (directive.name === "pull-quote") {
|
|
219
|
+
const attribution = directive.attributes.attribution;
|
|
220
|
+
return (
|
|
221
|
+
<blockquote
|
|
222
|
+
className="my-4 border-y border-border px-4 py-3 text-lg font-medium italic"
|
|
223
|
+
data-markus-preview-directive="pull-quote"
|
|
224
|
+
key={index}
|
|
225
|
+
>
|
|
226
|
+
{renderBlocks(directive.children, context)}
|
|
227
|
+
{typeof attribution === "string" && attribution ? (
|
|
228
|
+
<footer className="mt-1 text-sm not-italic text-muted-foreground">{`— ${attribution}`}</footer>
|
|
229
|
+
) : null}
|
|
230
|
+
</blockquote>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const attributeEntries = Object.entries(directive.attributes).filter(([, value]) => value !== null && value !== "");
|
|
234
|
+
return (
|
|
235
|
+
<section
|
|
236
|
+
className="my-3 rounded-lg border border-border"
|
|
237
|
+
data-markus-preview-directive={directive.name}
|
|
238
|
+
key={index}
|
|
239
|
+
>
|
|
240
|
+
<header className="border-b border-border bg-muted/40 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
241
|
+
{directive.name}
|
|
242
|
+
</header>
|
|
243
|
+
<div className="px-3 py-1">
|
|
244
|
+
{directive.leaf && attributeEntries.length > 0 ? (
|
|
245
|
+
<p className="my-2 font-mono text-xs text-muted-foreground">
|
|
246
|
+
{attributeEntries.map(([key, value]) => `${key}=${String(value)}`).join(" ")}
|
|
247
|
+
</p>
|
|
248
|
+
) : null}
|
|
249
|
+
{renderBlocks(directive.children, context)}
|
|
250
|
+
</div>
|
|
251
|
+
</section>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function renderBlocks(nodes: MarkusNode[], context: PreviewContext): ReactNode[] {
|
|
256
|
+
return nodes.map((node, index) =>
|
|
257
|
+
node.type === "directive" ? renderDirective(node, index, context) : renderBlock(node, index, context),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function MarkusIrPreview({ bodyIr }: { bodyIr: unknown }) {
|
|
262
|
+
try {
|
|
263
|
+
const envelope = typeof bodyIr === "string" ? JSON.parse(bodyIr) : bodyIr;
|
|
264
|
+
if (!isRecord(envelope)) throw new Error("bodyIr must be a JSON object");
|
|
265
|
+
const document = parseMarkusDocument(envelope.document);
|
|
266
|
+
const sidecar = readSidecar(envelope);
|
|
267
|
+
const context: PreviewContext = {
|
|
268
|
+
sidecar,
|
|
269
|
+
citationNumbers: numberCitationsByFirstAppearance(envelope.document, sidecar),
|
|
270
|
+
};
|
|
271
|
+
return <div data-markus-preview>{renderBlocks(document.children, context)}</div>;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return (
|
|
274
|
+
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" data-markus-preview-error role="alert">
|
|
275
|
+
{`The preview could not be rendered: ${error instanceof Error ? error.message : "unknown error"}`}
|
|
276
|
+
</p>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { SITE_BRAND } from "../lib/site-brand";
|
|
5
|
+
import type { NewsDeskShellState } from "../lib/news-desk-session";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
import { NewsroomOpsShell } from "./newsroom-ops-shell";
|
|
8
|
+
import { ReaderAuthControl } from "./reader-auth-control";
|
|
9
|
+
import { buttonVariants } from "./ui/button";
|
|
10
|
+
|
|
11
|
+
export function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
|
|
12
|
+
const accessPhase = shell?.phase ?? "checkingAccess";
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<NewsroomOpsShell
|
|
16
|
+
activeTab="overview"
|
|
17
|
+
appTitle={SITE_BRAND.appTitle}
|
|
18
|
+
backHref="/"
|
|
19
|
+
backLabel={SITE_BRAND.backToHomeLabel}
|
|
20
|
+
headerActions={(
|
|
21
|
+
<Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
|
|
22
|
+
)}
|
|
23
|
+
pageTitle="Newsroom"
|
|
24
|
+
showNavigation={showSectionTabs}
|
|
25
|
+
>
|
|
26
|
+
<section aria-live="polite" data-news-desk-access={accessPhase} data-news-desk-access-phase={accessPhase}>
|
|
27
|
+
<div className="space-y-3 rounded-xl border border-border bg-card p-6">
|
|
28
|
+
<p className="m-0 text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Access</p>
|
|
29
|
+
<h2 className="m-0 font-sans text-xl font-semibold tracking-tight">{formatAccessTitle(shell)}</h2>
|
|
30
|
+
<p className="m-0 text-sm text-muted-foreground">{formatAccessDetail(shell)}</p>
|
|
31
|
+
{shell?.error ? <p className="text-sm text-destructive">{shell.error}</p> : null}
|
|
32
|
+
<p className="m-0 text-sm text-foreground">{formatAccessActionDetail(shell)}</p>
|
|
33
|
+
{shell?.phase === "signedOut" || shell?.phase === "error" ? (
|
|
34
|
+
<div className="news-desk-access-panel__auth">
|
|
35
|
+
<ReaderAuthControl postAuthPath="/newsroom" showIdentity />
|
|
36
|
+
</div>
|
|
37
|
+
) : null}
|
|
38
|
+
</div>
|
|
39
|
+
</section>
|
|
40
|
+
</NewsroomOpsShell>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatAccessTitle(state: NewsDeskShellState | null): string {
|
|
45
|
+
if (!state || state.phase === "checkingAccess") return "Checking Desk Credentials";
|
|
46
|
+
if (state.phase === "loadingDesk") return "Loading Newsroom Records";
|
|
47
|
+
if (state.phase === "forbidden") return "Editor Role Required";
|
|
48
|
+
if (state.phase === "error") return "Newsroom Unavailable";
|
|
49
|
+
return "Editor Sign-In Required";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatAccessDetail(state: NewsDeskShellState | null): string {
|
|
53
|
+
if (!state || state.phase === "checkingAccess") return `${SITE_BRAND.appTitle} is checking the current browser session before loading steering state.`;
|
|
54
|
+
if (state.phase === "loadingDesk") return `${SITE_BRAND.appTitle} verified the browser session and is loading private Newsroom records.`;
|
|
55
|
+
if (state.phase === "forbidden") return "This account is signed in, but the Cognito session does not include the editor or admin group.";
|
|
56
|
+
if (state.phase === "error") return `${SITE_BRAND.appTitle} could not verify this editor session or load the private Newsroom data.`;
|
|
57
|
+
return "Sign in with an editor or admin account to inspect category, category tree, ontology, and graph steering.";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatAccessActionDetail(state: NewsDeskShellState | null): string {
|
|
61
|
+
if (!state || state.phase === "checkingAccess") return "This should only take a moment. If it hangs, reload the page.";
|
|
62
|
+
if (state.phase === "loadingDesk") return "Private records are loading from GraphQL. Large reference queues may take several seconds.";
|
|
63
|
+
if (state.phase === "forbidden") return "Ask an admin to add this account to the editor or admin group, then sign out and back in.";
|
|
64
|
+
if (state.phase === "error") return "Reload the page or check the local development console for the GraphQL error.";
|
|
65
|
+
return "Use the login button above to authenticate with an editor or admin account.";
|
|
66
|
+
}
|