@anthusai/papyrus 5.0.0 → 5.2.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.
Files changed (38) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +8 -0
  4. package/amplify/data/schema.ts +9 -0
  5. package/amplify/functions/content-actions/handler.py +15 -0
  6. package/amplify/functions/monitoring-view/handler.py +11 -0
  7. package/amplify/functions/monitoring-view/resource.js +24 -0
  8. package/amplify/functions/monitoring-view/resource.ts +34 -0
  9. package/amplify/functions/monitoring-view/schema.js +16 -0
  10. package/amplify/functions/monitoring-view/schema.ts +18 -0
  11. package/amplify/functions/monitoring-view/wire.js +37 -0
  12. package/amplify/functions/monitoring-view/wire.ts +47 -0
  13. package/amplify/publication/redirect-and-index.js +49 -0
  14. package/amplify/publication/redirect-rules.js +26 -10
  15. package/amplify/publication/redirect-rules.ts +36 -10
  16. package/amplify/publication/s3-projection.js +4 -2
  17. package/amplify/publication/s3-projection.ts +4 -2
  18. package/amplify/site-backend.js +9 -0
  19. package/amplify/site-backend.ts +9 -0
  20. package/components/monitoring-overview-view.tsx +240 -0
  21. package/components/monitoring-panel.tsx +40 -0
  22. package/components/newsroom-organizer.css +17 -0
  23. package/components/newsroom-references-view.tsx +14 -14
  24. package/components/publication-cyclotron-panel.tsx +10 -10
  25. package/components/reference-add-entry.tsx +75 -0
  26. package/components/reference-curation-controls.tsx +30 -21
  27. package/components/reference-cyclotron-priming.tsx +4 -4
  28. package/components/reference-cyclotron-suggestion.tsx +1 -1
  29. package/components/reference-priming.tsx +14 -10
  30. package/components/reference-section-suggestion.tsx +1 -1
  31. package/components/topic-steering-workspace.tsx +13 -2
  32. package/lib/category-repository.ts +1 -1
  33. package/lib/monitoring-client.ts +29 -0
  34. package/lib/monitoring-overview-fixture.ts +51 -0
  35. package/lib/monitoring-overview.ts +144 -0
  36. package/lib/relevance-status.ts +2 -2
  37. package/package.json +8 -2
  38. package/routes.manifest.json +1 -1
@@ -607,6 +607,12 @@ const schema = a.schema({
607
607
  .returns(a.json())
608
608
  .authorization((allow) => [allow.groups(contentWriteGroups)])
609
609
  .handler(a.handler.function(contentActions)),
610
+ moveStoryStage: a
611
+ .mutation()
612
+ .arguments({ input: a.json().required() })
613
+ .returns(a.json())
614
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
615
+ .handler(a.handler.function(contentActions)),
610
616
  unpublishItem: a
611
617
  .mutation()
612
618
  .arguments({ input: a.json().required() })
@@ -662,6 +662,13 @@ const schema = a.schema({
662
662
  .authorization((allow) => [allow.groups(contentWriteGroups)])
663
663
  .handler(a.handler.function(contentActions)),
664
664
 
665
+ moveStoryStage: a
666
+ .mutation()
667
+ .arguments({ input: a.json().required() })
668
+ .returns(a.json())
669
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
670
+ .handler(a.handler.function(contentActions)),
671
+
665
672
  unpublishItem: a
666
673
  .mutation()
667
674
  .arguments({ input: a.json().required() })
@@ -2,6 +2,7 @@ import { a } from "@aws-amplify/backend";
2
2
  import { assignmentAction } from "../functions/assignment-action/resource.js";
3
3
  import { categoryAction } from "../functions/category-action/resource.js";
4
4
  import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema.js";
5
+ import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema.js";
5
6
  import { contentActions } from "../functions/content-actions/resource.js";
6
7
  import { knowledgeQuery } from "../functions/knowledge-query/resource.js";
7
8
  import { manageUserRole } from "../functions/manage-user-role/resource.js";
@@ -515,6 +516,12 @@ const baseDefinitions = {
515
516
  .returns(a.json())
516
517
  .authorization((allow) => [allow.groups(contentWriteGroups)])
517
518
  .handler(a.handler.function(contentActions)),
519
+ moveStoryStage: a
520
+ .mutation()
521
+ .arguments({ input: a.json().required() })
522
+ .returns(a.json())
523
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
524
+ .handler(a.handler.function(contentActions)),
518
525
  unpublishItem: a
519
526
  .mutation()
520
527
  .arguments({ input: a.json().required() })
@@ -1995,6 +2002,7 @@ const baseDefinitions = {
1995
2002
  allow.groups(contentWriteGroups),
1996
2003
  ]),
1997
2004
  ...editorialChecksSchemaDefinitions,
2005
+ ...monitoringViewSchemaDefinitions,
1998
2006
  };
1999
2007
  export const schema = a.schema(baseDefinitions).authorization((allow) => [
2000
2008
  allow.resource(assignmentAction).to(["query", "mutate"]),
@@ -2,6 +2,7 @@ import { type ClientSchema, a } from "@aws-amplify/backend";
2
2
  import { assignmentAction } from "../functions/assignment-action/resource";
3
3
  import { categoryAction } from "../functions/category-action/resource";
4
4
  import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema";
5
+ import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema";
5
6
  import { contentActions } from "../functions/content-actions/resource";
6
7
  import { knowledgeQuery } from "../functions/knowledge-query/resource";
7
8
  import { manageUserRole } from "../functions/manage-user-role/resource";
@@ -564,6 +565,13 @@ const baseDefinitions = {
564
565
  .authorization((allow) => [allow.groups(contentWriteGroups)])
565
566
  .handler(a.handler.function(contentActions)),
566
567
 
568
+ moveStoryStage: a
569
+ .mutation()
570
+ .arguments({ input: a.json().required() })
571
+ .returns(a.json())
572
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
573
+ .handler(a.handler.function(contentActions)),
574
+
567
575
  unpublishItem: a
568
576
  .mutation()
569
577
  .arguments({ input: a.json().required() })
@@ -2114,6 +2122,7 @@ const baseDefinitions = {
2114
2122
  allow.groups(contentWriteGroups),
2115
2123
  ]),
2116
2124
  ...editorialChecksSchemaDefinitions,
2125
+ ...monitoringViewSchemaDefinitions,
2117
2126
  };
2118
2127
 
2119
2128
  export const schema = a.schema(baseDefinitions).authorization((allow) => [
@@ -23,6 +23,7 @@ from papyrus_content.rebuild_trigger import (
23
23
  trigger_staging_build,
24
24
  )
25
25
  from papyrus_content.story_reporting import save_story_version
26
+ from papyrus_content.publication import ensure_publication_from_environment
26
27
  from papyrus_content.reference_intake import IntakeError, intake_references
27
28
  from papyrus_content.publishing import (
28
29
  ItemFields,
@@ -151,6 +152,7 @@ def schedule_unpurged_invalidation(keys: list[str]) -> None:
151
152
 
152
153
  def run_deploy_render_task(client) -> dict:
153
154
  """Render every published page after a backend deploy, then report drift (a dry run: nothing is deleted)."""
155
+ ensure_publication_from_environment(client)
154
156
  context = projection_context(client)
155
157
  report = deploy_render(context)
156
158
  print(drift_metric_line(report))
@@ -284,6 +286,17 @@ def unpublish_item_action(arguments: dict, actor: str) -> dict:
284
286
  return response
285
287
 
286
288
 
289
+ def move_story_stage_action(arguments: dict, actor: str) -> dict:
290
+ from papyrus_content.story_reporting import move_story_stage
291
+
292
+ client, _claims = create_authoring_client()
293
+ try:
294
+ result = move_story_stage(client, arguments["lineageId"], arguments["stage"], actor=actor, reason=arguments.get("reason"))
295
+ except ValueError as error:
296
+ return {"ok": False, "errors": [{"code": "invalid-stage", "message": str(error), "line": None}]}
297
+ return {"ok": True, **result}
298
+
299
+
287
300
  def request_staging_build_action() -> dict:
288
301
  cms_app_id = (os.environ.get("PAPYRUS_CMS_AMPLIFY_APP_ID") or "").strip()
289
302
  if not cms_app_id:
@@ -312,6 +325,8 @@ def handler(event, context):
312
325
  return save_item_draft_action(arguments, actor)
313
326
  if field == "publishItem":
314
327
  return publish_item_action(arguments, actor)
328
+ if field == "moveStoryStage":
329
+ return move_story_stage_action(arguments, actor)
315
330
  if field == "unpublishItem":
316
331
  return unpublish_item_action(arguments, actor)
317
332
  if field == "requestStagingBuild":
@@ -0,0 +1,11 @@
1
+ """The monitoring-view function: `monitoringOverview` for editors and admins, read-only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from papyrus_content.monitoring_view import handle
8
+
9
+
10
+ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
11
+ return handle(event)
@@ -0,0 +1,24 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
4
+ import { pythonLambdaCode } from "../shared/python-bundle.js";
5
+ /** AppSync gives a resolver 30 seconds; a read of one small table needs a few. */
6
+ export const MONITORING_VIEW_TIMEOUT_SECONDS = 15;
7
+ /** stdlib plus the runtime's boto3: the bundle is `papyrus_content` and nothing else. */
8
+ export const monitoringView = defineFunction((scope) => new Function(scope, "papyrus-monitoring-view", {
9
+ runtime: Runtime.PYTHON_3_12,
10
+ architecture: Architecture.ARM_64,
11
+ handler: "handler.handler",
12
+ timeout: Duration.seconds(MONITORING_VIEW_TIMEOUT_SECONDS),
13
+ memorySize: 256,
14
+ code: pythonLambdaCode({ functionDir: "monitoring-view", modules: ["papyrus_content"], noDeps: true }, [
15
+ "bash",
16
+ "-c",
17
+ [
18
+ "set -euo pipefail",
19
+ "mkdir -p /asset-output/papyrus_content",
20
+ "cp amplify/functions/monitoring-view/handler.py /asset-output/handler.py",
21
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
22
+ ].join(" && "),
23
+ ]),
24
+ }), { resourceGroupName: "data" });
@@ -0,0 +1,34 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import { Architecture, Function, Runtime } from "aws-cdk-lib/aws-lambda";
4
+ import type { Construct } from "constructs";
5
+ import { pythonLambdaCode } from "../shared/python-bundle";
6
+
7
+ /** AppSync gives a resolver 30 seconds; a read of one small table needs a few. */
8
+ export const MONITORING_VIEW_TIMEOUT_SECONDS = 15;
9
+
10
+ /** stdlib plus the runtime's boto3: the bundle is `papyrus_content` and nothing else. */
11
+ export const monitoringView = defineFunction(
12
+ (scope: Construct) =>
13
+ new Function(scope, "papyrus-monitoring-view", {
14
+ runtime: Runtime.PYTHON_3_12,
15
+ architecture: Architecture.ARM_64,
16
+ handler: "handler.handler",
17
+ timeout: Duration.seconds(MONITORING_VIEW_TIMEOUT_SECONDS),
18
+ memorySize: 256,
19
+ code: pythonLambdaCode(
20
+ { functionDir: "monitoring-view", modules: ["papyrus_content"], noDeps: true },
21
+ [
22
+ "bash",
23
+ "-c",
24
+ [
25
+ "set -euo pipefail",
26
+ "mkdir -p /asset-output/papyrus_content",
27
+ "cp amplify/functions/monitoring-view/handler.py /asset-output/handler.py",
28
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
29
+ ].join(" && "),
30
+ ],
31
+ ),
32
+ }),
33
+ { resourceGroupName: "data" },
34
+ );
@@ -0,0 +1,16 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { monitoringView } from "./resource.js";
3
+ const editors = ["editor", "admin"];
4
+ /**
5
+ * Core to every site's data API. The Monitoring page reads the shared monitoring pipeline through
6
+ * this one query. AppSync admits editors and admins only; the function checks the groups again,
7
+ * and returns the whole shared pipeline to admins alone.
8
+ */
9
+ export const monitoringViewSchemaDefinitions = {
10
+ monitoringOverview: a
11
+ .query()
12
+ .arguments({ scope: a.string() })
13
+ .returns(a.json())
14
+ .authorization((allow) => [allow.groups(editors)])
15
+ .handler(a.handler.function(monitoringView)),
16
+ };
@@ -0,0 +1,18 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { monitoringView } from "./resource";
3
+
4
+ const editors = ["editor", "admin"];
5
+
6
+ /**
7
+ * Core to every site's data API. The Monitoring page reads the shared monitoring pipeline through
8
+ * this one query. AppSync admits editors and admins only; the function checks the groups again,
9
+ * and returns the whole shared pipeline to admins alone.
10
+ */
11
+ export const monitoringViewSchemaDefinitions = {
12
+ monitoringOverview: a
13
+ .query()
14
+ .arguments({ scope: a.string() })
15
+ .returns(a.json())
16
+ .authorization((allow) => [allow.groups(editors)])
17
+ .handler(a.handler.function(monitoringView)),
18
+ };
@@ -0,0 +1,37 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ /** The monitoring stack writes the table's name here (infra/monitoring/papyrus-monitoring.yaml). */
4
+ export const MONITORING_TABLE_PARAMETER = "/papyrus/monitoring/table-name";
5
+ /** Reads only: a single item or a key-range query. No Scan, no writes, no other table. */
6
+ export const MONITORING_VIEW_TABLE_ACTIONS = ["dynamodb:GetItem", "dynamodb:Query"];
7
+ /**
8
+ * The monitoring table is auto-named by CloudFormation (`papyrus-monitoring-MonitoringTable-<id>`), so
9
+ * the grant is the stack's table name prefix, not a wildcard on the account.
10
+ */
11
+ export function monitoringViewTableArns(region, account) {
12
+ const table = `arn:aws:dynamodb:${region}:${account}:table/papyrus-monitoring-MonitoringTable-*`;
13
+ return [table, `${table}/index/routing`];
14
+ }
15
+ export function monitoringViewParameterArn(region, account) {
16
+ return `arn:aws:ssm:${region}:${account}:parameter${MONITORING_TABLE_PARAMETER}`;
17
+ }
18
+ /**
19
+ * Environment and least-privilege IAM for the monitoring-view function. A site outside the monitoring
20
+ * account simply finds no parameter and the page says monitoring is not configured.
21
+ */
22
+ export function wireMonitoringView(backend) {
23
+ const lambda = backend.monitoringView.resources.lambda;
24
+ const stack = Stack.of(lambda);
25
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
26
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
27
+ lambda.addToRolePolicy(new PolicyStatement({
28
+ effect: Effect.ALLOW,
29
+ actions: ["ssm:GetParameter"],
30
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
31
+ }));
32
+ lambda.addToRolePolicy(new PolicyStatement({
33
+ effect: Effect.ALLOW,
34
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
35
+ resources: monitoringViewTableArns(stack.region, stack.account),
36
+ }));
37
+ }
@@ -0,0 +1,47 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
4
+
5
+ /** The monitoring stack writes the table's name here (infra/monitoring/papyrus-monitoring.yaml). */
6
+ export const MONITORING_TABLE_PARAMETER = "/papyrus/monitoring/table-name";
7
+
8
+ /** Reads only: a single item or a key-range query. No Scan, no writes, no other table. */
9
+ export const MONITORING_VIEW_TABLE_ACTIONS = ["dynamodb:GetItem", "dynamodb:Query"];
10
+
11
+ /**
12
+ * The monitoring table is auto-named by CloudFormation (`papyrus-monitoring-MonitoringTable-<id>`), so
13
+ * the grant is the stack's table name prefix, not a wildcard on the account.
14
+ */
15
+ export function monitoringViewTableArns(region: string, account: string): string[] {
16
+ const table = `arn:aws:dynamodb:${region}:${account}:table/papyrus-monitoring-MonitoringTable-*`;
17
+ return [table, `${table}/index/routing`];
18
+ }
19
+
20
+ export function monitoringViewParameterArn(region: string, account: string): string {
21
+ return `arn:aws:ssm:${region}:${account}:parameter${MONITORING_TABLE_PARAMETER}`;
22
+ }
23
+
24
+ /**
25
+ * Environment and least-privilege IAM for the monitoring-view function. A site outside the monitoring
26
+ * account simply finds no parameter and the page says monitoring is not configured.
27
+ */
28
+ export function wireMonitoringView(backend: any): void {
29
+ const lambda = backend.monitoringView.resources.lambda as LambdaFunction;
30
+ const stack = Stack.of(lambda);
31
+ lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
32
+ lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
33
+ lambda.addToRolePolicy(
34
+ new PolicyStatement({
35
+ effect: Effect.ALLOW,
36
+ actions: ["ssm:GetParameter"],
37
+ resources: [monitoringViewParameterArn(stack.region, stack.account)],
38
+ }),
39
+ );
40
+ lambda.addToRolePolicy(
41
+ new PolicyStatement({
42
+ effect: Effect.ALLOW,
43
+ actions: MONITORING_VIEW_TABLE_ACTIONS,
44
+ resources: monitoringViewTableArns(stack.region, stack.account),
45
+ }),
46
+ );
47
+ }
@@ -45,6 +45,49 @@ function lookUpPatternRedirect(uri) {
45
45
  return null;
46
46
  }
47
47
 
48
+ // Content negotiation for agents: when the site publishes Markdown twins (standard front end, agents.markdown), a request
49
+ // whose Accept header prefers text/markdown over text/html is rewritten to the .md object before the cache lookup. The
50
+ // flag is spliced in by redirect-rules.ts at deploy time.
51
+ const negotiateMarkdown = /*PAPYRUS_NEGOTIATE_MARKDOWN*/false/*END_PAPYRUS_NEGOTIATE_MARKDOWN*/;
52
+
53
+ function qualityOf(accept, mediaType) {
54
+ let quality = 0;
55
+ const parts = accept.toLowerCase().split(",");
56
+ for (let index = 0; index < parts.length; index += 1) {
57
+ const fields = parts[index].split(";");
58
+ if (fields[0].trim() !== mediaType) {
59
+ continue;
60
+ }
61
+ quality = 1;
62
+ for (let field = 1; field < fields.length; field += 1) {
63
+ const pair = fields[field].trim();
64
+ if (pair.indexOf("q=") === 0) {
65
+ quality = Number(pair.substring(2));
66
+ if (isNaN(quality)) {
67
+ quality = 0;
68
+ }
69
+ }
70
+ }
71
+ }
72
+ return quality;
73
+ }
74
+
75
+ function prefersMarkdown(request) {
76
+ const header = request.headers && request.headers.accept;
77
+ if (!header || !header.value) {
78
+ return false;
79
+ }
80
+ const markdown = qualityOf(header.value, "text/markdown");
81
+ return markdown > 0 && markdown > qualityOf(header.value, "text/html");
82
+ }
83
+
84
+ function markdownTwin(uri) {
85
+ if (uri === "/404.html" || uri.length < 6 || uri.substring(uri.length - 5) !== ".html") {
86
+ return null;
87
+ }
88
+ return uri.substring(0, uri.length - 5) + ".md";
89
+ }
90
+
48
91
  async function handler(event) {
49
92
  const request = event.request;
50
93
  const redirectTarget = await lookUpRedirectTarget(request.uri);
@@ -64,5 +107,11 @@ async function handler(event) {
64
107
  };
65
108
  }
66
109
  request.uri = rewriteToIndexDocument(request.uri);
110
+ if (negotiateMarkdown && prefersMarkdown(request)) {
111
+ const twin = markdownTwin(request.uri);
112
+ if (twin !== null) {
113
+ request.uri = twin;
114
+ }
115
+ }
67
116
  return request;
68
117
  }
@@ -18,23 +18,39 @@ export function edgeRulesFromSettings(settings) {
18
18
  }
19
19
  return rules;
20
20
  }
21
- export function readEdgeRules(staticDir, baseDirectory = process.cwd()) {
21
+ /** A standard-front-end site publishes Markdown twins unless it turns `agents.markdown` off. */
22
+ export function negotiatesMarkdown(settings) {
23
+ return settings.site !== undefined && settings.agents?.markdown !== false;
24
+ }
25
+ export function readEdgeConfig(staticDir, baseDirectory = process.cwd()) {
22
26
  if (staticDir === undefined)
23
- return [];
27
+ return { rules: [], negotiateMarkdown: false };
24
28
  const settingsPath = resolve(baseDirectory, staticDir, "projection.json");
25
29
  if (!existsSync(settingsPath))
26
- return [];
27
- return edgeRulesFromSettings(JSON.parse(readFileSync(settingsPath, "utf8")));
30
+ return { rules: [], negotiateMarkdown: false };
31
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
32
+ return { rules: edgeRulesFromSettings(settings), negotiateMarkdown: negotiatesMarkdown(settings) };
28
33
  }
29
- /** The CloudFront Function source with the site's pattern rules spliced in; the source unchanged when there are none. */
30
- export function compileRedirectFunctionSource(source, rules) {
34
+ export function readEdgeRules(staticDir, baseDirectory = process.cwd()) {
35
+ return readEdgeConfig(staticDir, baseDirectory).rules;
36
+ }
37
+ const NEGOTIATE_START = "/*PAPYRUS_NEGOTIATE_MARKDOWN*/";
38
+ const NEGOTIATE_END = "/*END_PAPYRUS_NEGOTIATE_MARKDOWN*/";
39
+ /** The CloudFront Function source with the site's pattern rules and Markdown negotiation flag spliced in. */
40
+ export function compileRedirectFunctionSource(source, rules, options = {}) {
31
41
  const start = source.indexOf(START_MARKER);
32
42
  const end = source.indexOf(END_MARKER);
33
43
  if (start === -1 || end === -1 || end < start) {
34
44
  throw new Error("redirect function source has no pattern-rule markers");
35
45
  }
36
- if (rules.length === 0)
37
- return source;
38
- const literal = JSON.stringify(rules);
39
- return source.slice(0, start + START_MARKER.length) + literal + source.slice(end);
46
+ let compiled = rules.length === 0 ? source : source.slice(0, start + START_MARKER.length) + JSON.stringify(rules) + source.slice(end);
47
+ if (options.negotiateMarkdown === true) {
48
+ const from = compiled.indexOf(NEGOTIATE_START);
49
+ const to = compiled.indexOf(NEGOTIATE_END);
50
+ if (from === -1 || to === -1 || to < from) {
51
+ throw new Error("redirect function source has no markdown negotiation markers");
52
+ }
53
+ compiled = compiled.slice(0, from + NEGOTIATE_START.length) + "true" + compiled.slice(to);
54
+ }
55
+ return compiled;
40
56
  }
@@ -8,7 +8,7 @@ const START_MARKER = "/*PAPYRUS_PATTERN_RULES*/";
8
8
  const END_MARKER = "/*END_PAPYRUS_PATTERN_RULES*/";
9
9
 
10
10
  type ProjectionRedirects = { patterns?: { pattern: string; to: string; status?: number; except?: string[] }[] };
11
- type ProjectionSettings = { urls?: { style?: string }; redirects?: ProjectionRedirects };
11
+ type ProjectionSettings = { site?: unknown; urls?: { style?: string }; redirects?: ProjectionRedirects; agents?: { markdown?: boolean } };
12
12
 
13
13
  /**
14
14
  * The pattern rules a site declares in projection.json: its `redirects.patterns`, plus, for
@@ -27,21 +27,47 @@ export function edgeRulesFromSettings(settings: ProjectionSettings): EdgeRedirec
27
27
  return rules;
28
28
  }
29
29
 
30
- export function readEdgeRules(staticDir: string | undefined, baseDirectory: string = process.cwd()): EdgeRedirectRule[] {
31
- if (staticDir === undefined) return [];
30
+ /** A standard-front-end site publishes Markdown twins unless it turns `agents.markdown` off. */
31
+ export function negotiatesMarkdown(settings: ProjectionSettings): boolean {
32
+ return settings.site !== undefined && settings.agents?.markdown !== false;
33
+ }
34
+
35
+ export type EdgeConfig = { rules: EdgeRedirectRule[]; negotiateMarkdown: boolean };
36
+
37
+ export function readEdgeConfig(staticDir: string | undefined, baseDirectory: string = process.cwd()): EdgeConfig {
38
+ if (staticDir === undefined) return { rules: [], negotiateMarkdown: false };
32
39
  const settingsPath = resolve(baseDirectory, staticDir, "projection.json");
33
- if (!existsSync(settingsPath)) return [];
34
- return edgeRulesFromSettings(JSON.parse(readFileSync(settingsPath, "utf8")) as ProjectionSettings);
40
+ if (!existsSync(settingsPath)) return { rules: [], negotiateMarkdown: false };
41
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as ProjectionSettings;
42
+ return { rules: edgeRulesFromSettings(settings), negotiateMarkdown: negotiatesMarkdown(settings) };
35
43
  }
36
44
 
37
- /** The CloudFront Function source with the site's pattern rules spliced in; the source unchanged when there are none. */
38
- export function compileRedirectFunctionSource(source: string, rules: EdgeRedirectRule[]): string {
45
+ export function readEdgeRules(staticDir: string | undefined, baseDirectory: string = process.cwd()): EdgeRedirectRule[] {
46
+ return readEdgeConfig(staticDir, baseDirectory).rules;
47
+ }
48
+
49
+ const NEGOTIATE_START = "/*PAPYRUS_NEGOTIATE_MARKDOWN*/";
50
+ const NEGOTIATE_END = "/*END_PAPYRUS_NEGOTIATE_MARKDOWN*/";
51
+
52
+ /** The CloudFront Function source with the site's pattern rules and Markdown negotiation flag spliced in. */
53
+ export function compileRedirectFunctionSource(
54
+ source: string,
55
+ rules: EdgeRedirectRule[],
56
+ options: { negotiateMarkdown?: boolean } = {},
57
+ ): string {
39
58
  const start = source.indexOf(START_MARKER);
40
59
  const end = source.indexOf(END_MARKER);
41
60
  if (start === -1 || end === -1 || end < start) {
42
61
  throw new Error("redirect function source has no pattern-rule markers");
43
62
  }
44
- if (rules.length === 0) return source;
45
- const literal = JSON.stringify(rules);
46
- return source.slice(0, start + START_MARKER.length) + literal + source.slice(end);
63
+ let compiled = rules.length === 0 ? source : source.slice(0, start + START_MARKER.length) + JSON.stringify(rules) + source.slice(end);
64
+ if (options.negotiateMarkdown === true) {
65
+ const from = compiled.indexOf(NEGOTIATE_START);
66
+ const to = compiled.indexOf(NEGOTIATE_END);
67
+ if (from === -1 || to === -1 || to < from) {
68
+ throw new Error("redirect function source has no markdown negotiation markers");
69
+ }
70
+ compiled = compiled.slice(0, from + NEGOTIATE_START.length) + "true" + compiled.slice(to);
71
+ }
72
+ return compiled;
47
73
  }
@@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url";
13
13
  import { assertValidPublicationConfig } from "../site-backend-config.js";
14
14
  import { addRenditionsFunction } from "./renditions-function.js";
15
15
  import { RENDITIONS_FUNCTION_NAME_ENV } from "./renditions-settings.js";
16
- import { compileRedirectFunctionSource, readEdgeRules } from "./redirect-rules.js";
16
+ import { compileRedirectFunctionSource, readEdgeConfig } from "./redirect-rules.js";
17
17
  import { addInvalidationPathAlarm } from "./invalidation-alarm.js";
18
18
  import { distributionIdFromArn, s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
19
19
  const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
@@ -72,7 +72,9 @@ export function addS3ProjectionPublication(scope, props, media) {
72
72
  const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
73
73
  const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
74
74
  runtime: cloudfront.FunctionRuntime.JS_2_0,
75
- code: cloudfront.FunctionCode.fromInline(compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), readEdgeRules(props.staticDir))),
75
+ code: cloudfront.FunctionCode.fromInline(compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), readEdgeConfig(props.staticDir).rules, {
76
+ negotiateMarkdown: readEdgeConfig(props.staticDir).negotiateMarkdown,
77
+ })),
76
78
  keyValueStore,
77
79
  });
78
80
  const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
@@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url";
16
16
  import { assertValidPublicationConfig, type PapyrusPublicationConfig } from "../site-backend-config";
17
17
  import { addRenditionsFunction, type RenditionsFunction } from "./renditions-function";
18
18
  import { RENDITIONS_FUNCTION_NAME_ENV, type RenditionsPlan } from "./renditions-settings";
19
- import { compileRedirectFunctionSource, readEdgeRules } from "./redirect-rules";
19
+ import { compileRedirectFunctionSource, readEdgeConfig } from "./redirect-rules";
20
20
  import { addInvalidationPathAlarm } from "./invalidation-alarm";
21
21
  import { distributionIdFromArn, s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
22
22
 
@@ -115,7 +115,9 @@ export function addS3ProjectionPublication(
115
115
  const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
116
116
  runtime: cloudfront.FunctionRuntime.JS_2_0,
117
117
  code: cloudfront.FunctionCode.fromInline(
118
- compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), readEdgeRules(props.staticDir)),
118
+ compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), readEdgeConfig(props.staticDir).rules, {
119
+ negotiateMarkdown: readEdgeConfig(props.staticDir).negotiateMarkdown,
120
+ }),
119
121
  ),
120
122
  keyValueStore,
121
123
  });
@@ -21,6 +21,8 @@ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRe
21
21
  import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client.js";
22
22
  import { defineSiteData } from "./data/resource.js";
23
23
  import { editorialChecks } from "./functions/editorial-checks/resource.js";
24
+ import { monitoringView } from "./functions/monitoring-view/resource.js";
25
+ import { wireMonitoringView } from "./functions/monitoring-view/wire.js";
24
26
  import { wireEditorialChecks } from "./functions/editorial-checks/wire.js";
25
27
  import { resolvePlugins } from "./plugins/registry.js";
26
28
  import { wirePlugins } from "./plugins/wire.js";
@@ -95,6 +97,7 @@ export function defineSiteBackend(papyrusSite) {
95
97
  data: defineSiteData(pluginDefinitions),
96
98
  knowledgeQuery,
97
99
  manageUserRole,
100
+ monitoringView,
98
101
  modelAttachmentUpload,
99
102
  newsroomSummary,
100
103
  procedureAction,
@@ -313,6 +316,11 @@ export function defineSiteBackend(papyrusSite) {
313
316
  grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda);
314
317
  const contentActionsLambda = backend.contentActions.resources.lambda;
315
318
  grantNewsroomReadWriteDelete(contentActionsLambda);
319
+ if ((activeBrand?.corpusKey ?? "").trim() !== "") {
320
+ // A deploy creates the publication's canonical corpus from the brand when the backend has none.
321
+ contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_CORPUS_KEY", (activeBrand?.corpusKey ?? "").trim());
322
+ contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_NAME", (activeBrand?.publicationName ?? activeBrand?.corpusKey ?? "").trim());
323
+ }
316
324
  // AppSync validates the token; pending intake additionally binds its actor
317
325
  // and publication to this deployment's pool and existing API endpoint.
318
326
  contentActionsLambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
@@ -593,6 +601,7 @@ export function defineSiteBackend(papyrusSite) {
593
601
  },
594
602
  });
595
603
  wireEditorialChecks(backend);
604
+ wireMonitoringView(backend);
596
605
  wirePlugins(backend, plugins, contentActionsLambda);
597
606
  return backend;
598
607
  }
@@ -28,6 +28,8 @@ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRe
28
28
  import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client";
29
29
  import { defineSiteData } from "./data/resource";
30
30
  import { editorialChecks } from "./functions/editorial-checks/resource";
31
+ import { monitoringView } from "./functions/monitoring-view/resource";
32
+ import { wireMonitoringView } from "./functions/monitoring-view/wire";
31
33
  import { wireEditorialChecks } from "./functions/editorial-checks/wire";
32
34
  import { resolvePlugins } from "./plugins/registry";
33
35
  import { wirePlugins } from "./plugins/wire";
@@ -116,6 +118,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
116
118
  data: defineSiteData(pluginDefinitions),
117
119
  knowledgeQuery,
118
120
  manageUserRole,
121
+ monitoringView,
119
122
  modelAttachmentUpload,
120
123
  newsroomSummary,
121
124
  procedureAction,
@@ -399,6 +402,11 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
399
402
  grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda as LambdaFunction);
400
403
  const contentActionsLambda = backend.contentActions.resources.lambda as LambdaFunction;
401
404
  grantNewsroomReadWriteDelete(contentActionsLambda);
405
+ if ((activeBrand?.corpusKey ?? "").trim() !== "") {
406
+ // A deploy creates the publication's canonical corpus from the brand when the backend has none.
407
+ contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_CORPUS_KEY", (activeBrand?.corpusKey ?? "").trim());
408
+ contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_NAME", (activeBrand?.publicationName ?? activeBrand?.corpusKey ?? "").trim());
409
+ }
402
410
  // AppSync validates the token; pending intake additionally binds its actor
403
411
  // and publication to this deployment's pool and existing API endpoint.
404
412
  contentActionsLambda.addEnvironment(
@@ -736,6 +744,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
736
744
  },
737
745
  });
738
746
  wireEditorialChecks(backend);
747
+ wireMonitoringView(backend);
739
748
  wirePlugins(backend, plugins, contentActionsLambda);
740
749
 
741
750
  return backend;