@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.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +8 -0
- package/amplify/data/schema.ts +9 -0
- package/amplify/functions/content-actions/handler.py +15 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/publication/redirect-and-index.js +49 -0
- package/amplify/publication/redirect-rules.js +26 -10
- package/amplify/publication/redirect-rules.ts +36 -10
- package/amplify/publication/s3-projection.js +4 -2
- package/amplify/publication/s3-projection.ts +4 -2
- package/amplify/site-backend.js +9 -0
- package/amplify/site-backend.ts +9 -0
- package/components/monitoring-overview-view.tsx +240 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsroom-organizer.css +17 -0
- package/components/newsroom-references-view.tsx +14 -14
- package/components/publication-cyclotron-panel.tsx +10 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +30 -21
- package/components/reference-cyclotron-priming.tsx +4 -4
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +13 -2
- package/lib/category-repository.ts +1 -1
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +144 -0
- package/lib/relevance-status.ts +2 -2
- package/package.json +8 -2
- package/routes.manifest.json +1 -1
package/amplify/data/resource.js
CHANGED
|
@@ -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() })
|
package/amplify/data/resource.ts
CHANGED
|
@@ -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() })
|
package/amplify/data/schema.js
CHANGED
|
@@ -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"]),
|
package/amplify/data/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
+
return { rules: [], negotiateMarkdown: false };
|
|
31
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
32
|
+
return { rules: edgeRulesFromSettings(settings), negotiateMarkdown: negotiatesMarkdown(settings) };
|
|
28
33
|
}
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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,
|
|
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"),
|
|
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,
|
|
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"),
|
|
118
|
+
compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), readEdgeConfig(props.staticDir).rules, {
|
|
119
|
+
negotiateMarkdown: readEdgeConfig(props.staticDir).negotiateMarkdown,
|
|
120
|
+
}),
|
|
119
121
|
),
|
|
120
122
|
keyValueStore,
|
|
121
123
|
});
|
package/amplify/site-backend.js
CHANGED
|
@@ -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
|
}
|
package/amplify/site-backend.ts
CHANGED
|
@@ -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;
|