@anthusai/papyrus 2.0.0-next.4 → 2.0.0-next.41
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 +23 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +2030 -1967
- package/amplify/data/schema.ts +73 -2
- package/amplify/functions/assignment-action/data-access.js +88 -0
- package/amplify/functions/assignment-action/data-access.ts +87 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +36 -0
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/functions/editorial-checks/resource.js +43 -0
- package/amplify/functions/editorial-checks/resource.ts +60 -0
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/functions/editorial-checks/schema.ts +123 -0
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -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/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +9 -3
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/reader-steering/config.js +110 -0
- package/amplify/plugins/reader-steering/config.ts +153 -0
- package/amplify/plugins/reader-steering/function/handler.py +544 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +103 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +122 -0
- package/amplify/plugins/reader-steering/schema.js +40 -0
- package/amplify/plugins/reader-steering/schema.ts +42 -0
- package/amplify/plugins/registry.js +2 -0
- package/amplify/plugins/registry.ts +2 -0
- package/amplify/plugins/types.ts +3 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +17 -0
- package/amplify/site-backend.js +36 -1
- package/amplify/site-backend.ts +44 -1
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +362 -0
- package/components/editorial-finding-card.tsx +162 -0
- package/components/editorial-findings-panel.tsx +91 -0
- package/components/editorial-preview-popover.tsx +103 -0
- package/components/markus-body-editor.tsx +213 -0
- package/components/markus-ir-preview.tsx +147 -6
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsroom-article-editor.tsx +136 -11
- package/components/newsroom-board-view.tsx +6 -7
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-ops-shell.tsx +7 -3
- package/components/newsroom-organizer.css +74 -61
- package/components/newsroom-references-view.tsx +14 -14
- package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
- package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
- package/components/newsroom-theme-fonts/night-desk.css +10 -0
- package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
- package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
- package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
- package/components/newsroom-theme.css +39 -0
- package/components/newsroom-theme.tsx +33 -0
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- 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 +27 -2
- package/components/use-editorial-checks.ts +133 -0
- package/infra/amplify-app-shell.js +90 -16
- package/infra/amplify-app-shell.ts +102 -18
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +85 -6
- package/infra/site-config.ts +123 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +16 -0
- package/lib/editorial-checks-client.ts +212 -0
- package/lib/editorial-checks-demo.ts +147 -0
- package/lib/editorial-checks-settings.ts +27 -0
- package/lib/editorial-checks.ts +173 -0
- package/lib/editorial-preview.ts +228 -0
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +54 -13
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +143 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/relevance-status.ts +2 -2
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +23 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +56 -4
- package/routes.manifest.json +89 -1
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
- package/components/newsroom-newsprint-board.css +0 -7
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ArnPrincipal, Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
2
|
+
import { Duration, Stack } from "aws-cdk-lib";
|
|
3
|
+
import { Rule, RuleTargetInput, Schedule } from "aws-cdk-lib/aws-events";
|
|
4
|
+
import { LambdaFunction as LambdaFunctionTarget } from "aws-cdk-lib/aws-events-targets";
|
|
5
|
+
import { resolveReaderSteeringConfig } from "./config.js";
|
|
6
|
+
import { readerActions } from "./function/resource.js";
|
|
7
|
+
import { readerSteeringSchemaDefinitions } from "./schema.js";
|
|
8
|
+
export { resolveReaderSteeringConfig };
|
|
9
|
+
export const READER_STEERING_OUTPUT_KEY = "readerSteering";
|
|
10
|
+
export const SNAPSHOT_SCHEDULE_PREFIX = "papyrus-reader-snapshot-";
|
|
11
|
+
export const READER_SNAPSHOT_TASK = { projectionTask: { task: "reader-snapshot" } };
|
|
12
|
+
export const readerSteeringManifest = {
|
|
13
|
+
name: "reader-steering",
|
|
14
|
+
contract: 1,
|
|
15
|
+
models: ["ReaderProfile", "ReaderHandle", "ReaderVote"],
|
|
16
|
+
mutations: [],
|
|
17
|
+
guest: { mutations: [] },
|
|
18
|
+
newsroomPanels: [],
|
|
19
|
+
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
20
|
+
};
|
|
21
|
+
/** What the reader-actions function reads from `PAPYRUS_READER_SETTINGS`. */
|
|
22
|
+
export function readerActionsSettings(config, allowInterimSubjects) {
|
|
23
|
+
return {
|
|
24
|
+
dimensions: config.dimensions,
|
|
25
|
+
limits: config.limits,
|
|
26
|
+
reservedHandles: config.reservedHandles,
|
|
27
|
+
allowInterimSubjects,
|
|
28
|
+
snapshotDebounceSeconds: 60,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** What the `reader-snapshot` projection task reads from `PAPYRUS_READER_SNAPSHOT_SETTINGS`. */
|
|
32
|
+
export function readerSnapshotSettings(config) {
|
|
33
|
+
return {
|
|
34
|
+
dimensions: config.dimensions,
|
|
35
|
+
karma: config.karma,
|
|
36
|
+
ranking: config.ranking,
|
|
37
|
+
hypeMinimum: config.snapshot.hypeMinimum,
|
|
38
|
+
...(config.hypeGap ? { hypeGap: config.hypeGap } : {}),
|
|
39
|
+
retirementPoll: config.retirementPoll,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function interimSubjectsAllowedAtSynth(environment = process.env) {
|
|
43
|
+
return ["development", "test"].includes((environment.SITE_ENV ?? "").trim()) && environment.NODE_ENV !== "production";
|
|
44
|
+
}
|
|
45
|
+
export const readerSteeringPlugin = {
|
|
46
|
+
manifest: readerSteeringManifest,
|
|
47
|
+
resolveConfig: resolveReaderSteeringConfig,
|
|
48
|
+
functions: () => ({ readerActions }),
|
|
49
|
+
schemaDefinitions: readerSteeringSchemaDefinitions,
|
|
50
|
+
publicSettings: (config) => ({ contract: readerSteeringManifest.contract, dimensions: config.dimensions }),
|
|
51
|
+
wire(backend, config, context) {
|
|
52
|
+
const lambda = backend.readerActions.resources.lambda;
|
|
53
|
+
const tables = backend.data.resources.tables;
|
|
54
|
+
const contentActions = context.contentActionsLambda;
|
|
55
|
+
for (const [variable, model] of [
|
|
56
|
+
["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
|
|
57
|
+
["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
|
|
58
|
+
["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
|
|
59
|
+
]) {
|
|
60
|
+
tables[model].grantReadWriteData(lambda);
|
|
61
|
+
lambda.addEnvironment(variable, tables[model].tableName);
|
|
62
|
+
contentActions.addEnvironment(variable, tables[model].tableName);
|
|
63
|
+
}
|
|
64
|
+
tables.PublishedItem.grantReadData(lambda);
|
|
65
|
+
lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
|
|
66
|
+
tables.ReaderVote.grantReadData(contentActions);
|
|
67
|
+
tables.ReaderProfile.grantReadWriteData(contentActions);
|
|
68
|
+
lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
|
|
69
|
+
if (config.reservedConcurrency !== undefined) {
|
|
70
|
+
lambda.node.defaultChild.addPropertyOverride("ReservedConcurrentExecutions", config.reservedConcurrency);
|
|
71
|
+
}
|
|
72
|
+
// Trailing-edge debounce: the first write in a window creates a one-off EventBridge Scheduler schedule at the end of
|
|
73
|
+
// the window (docs/reader-steering.md). The schedule invokes content-actions as this role; reader-actions may create
|
|
74
|
+
// schedules named for this feature, and pass exactly this role.
|
|
75
|
+
const schedulerRole = new Role(Stack.of(lambda), "ReaderSnapshotSchedulerRole", {
|
|
76
|
+
assumedBy: new ServicePrincipal("scheduler.amazonaws.com"),
|
|
77
|
+
description: "Lets one-off schedules created by reader-actions invoke the reader-snapshot run.",
|
|
78
|
+
});
|
|
79
|
+
contentActions.grantInvoke(schedulerRole);
|
|
80
|
+
const stack = Stack.of(lambda); // the role lives with the function, so the stacks keep their existing one-way dependency
|
|
81
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
82
|
+
effect: Effect.ALLOW,
|
|
83
|
+
actions: ["scheduler:CreateSchedule"],
|
|
84
|
+
resources: [`arn:${stack.partition}:scheduler:${stack.region}:${stack.account}:schedule/default/${SNAPSHOT_SCHEDULE_PREFIX}*`],
|
|
85
|
+
}));
|
|
86
|
+
lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["iam:PassRole"], resources: [schedulerRole.roleArn] }));
|
|
87
|
+
lambda.addEnvironment("PAPYRUS_READER_SNAPSHOT_FUNCTION_ARN", contentActions.functionArn);
|
|
88
|
+
lambda.addEnvironment("PAPYRUS_READER_SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
|
|
89
|
+
contentActions.addEnvironment("PAPYRUS_READER_SNAPSHOT_SETTINGS", JSON.stringify(readerSnapshotSettings(config)));
|
|
90
|
+
lambda.addPermission("InvokeFromCmsCompute", {
|
|
91
|
+
principal: new ArnPrincipal(config.computeRoleArn),
|
|
92
|
+
action: "lambda:InvokeFunction",
|
|
93
|
+
});
|
|
94
|
+
if (config.snapshot.schedule) {
|
|
95
|
+
new Rule(context.createStack("reader-steering-schedule"), "ReaderSnapshotSchedule", {
|
|
96
|
+
description: "Rebuilds the reader snapshot (time decay, Rising) and polls nothing else.",
|
|
97
|
+
schedule: Schedule.rate(Duration.hours(config.snapshot.intervalHours)),
|
|
98
|
+
targets: [new LambdaFunctionTarget(contentActions, { event: RuleTargetInput.fromObject(READER_SNAPSHOT_TASK), retryAttempts: 0 })],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
backend.addOutput({ custom: { [READER_STEERING_OUTPUT_KEY]: { actionsFunction: lambda.functionName } } });
|
|
102
|
+
},
|
|
103
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { ArnPrincipal, Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
2
|
+
import { Duration, Stack } from "aws-cdk-lib";
|
|
3
|
+
import type { ITable } from "aws-cdk-lib/aws-dynamodb";
|
|
4
|
+
import { Rule, RuleTargetInput, Schedule } from "aws-cdk-lib/aws-events";
|
|
5
|
+
import { LambdaFunction as LambdaFunctionTarget } from "aws-cdk-lib/aws-events-targets";
|
|
6
|
+
import { CfnFunction, type Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
|
|
7
|
+
import type { PapyrusPlugin, PluginManifest, PluginWiringContext } from "../types";
|
|
8
|
+
import { resolveReaderSteeringConfig, type ReaderSteeringConfig } from "./config";
|
|
9
|
+
import { readerActions } from "./function/resource";
|
|
10
|
+
import { readerSteeringSchemaDefinitions } from "./schema";
|
|
11
|
+
|
|
12
|
+
export { resolveReaderSteeringConfig };
|
|
13
|
+
export type { ReaderSteeringConfig };
|
|
14
|
+
|
|
15
|
+
export const READER_STEERING_OUTPUT_KEY = "readerSteering";
|
|
16
|
+
export const SNAPSHOT_SCHEDULE_PREFIX = "papyrus-reader-snapshot-";
|
|
17
|
+
export const READER_SNAPSHOT_TASK = { projectionTask: { task: "reader-snapshot" } };
|
|
18
|
+
|
|
19
|
+
export const readerSteeringManifest: PluginManifest = {
|
|
20
|
+
name: "reader-steering",
|
|
21
|
+
contract: 1,
|
|
22
|
+
models: ["ReaderProfile", "ReaderHandle", "ReaderVote"],
|
|
23
|
+
mutations: [],
|
|
24
|
+
guest: { mutations: [] },
|
|
25
|
+
newsroomPanels: [],
|
|
26
|
+
staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** What the reader-actions function reads from `PAPYRUS_READER_SETTINGS`. */
|
|
30
|
+
export function readerActionsSettings(config: ReaderSteeringConfig, allowInterimSubjects: boolean) {
|
|
31
|
+
return {
|
|
32
|
+
dimensions: config.dimensions,
|
|
33
|
+
limits: config.limits,
|
|
34
|
+
reservedHandles: config.reservedHandles,
|
|
35
|
+
allowInterimSubjects,
|
|
36
|
+
snapshotDebounceSeconds: 60,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What the `reader-snapshot` projection task reads from `PAPYRUS_READER_SNAPSHOT_SETTINGS`. */
|
|
41
|
+
export function readerSnapshotSettings(config: ReaderSteeringConfig) {
|
|
42
|
+
return {
|
|
43
|
+
dimensions: config.dimensions,
|
|
44
|
+
karma: config.karma,
|
|
45
|
+
ranking: config.ranking,
|
|
46
|
+
hypeMinimum: config.snapshot.hypeMinimum,
|
|
47
|
+
...(config.hypeGap ? { hypeGap: config.hypeGap } : {}),
|
|
48
|
+
retirementPoll: config.retirementPoll,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function interimSubjectsAllowedAtSynth(environment: Record<string, string | undefined> = process.env): boolean {
|
|
53
|
+
return ["development", "test"].includes((environment.SITE_ENV ?? "").trim()) && environment.NODE_ENV !== "production";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerActions: typeof readerActions }> = {
|
|
57
|
+
manifest: readerSteeringManifest,
|
|
58
|
+
resolveConfig: resolveReaderSteeringConfig,
|
|
59
|
+
functions: () => ({ readerActions }),
|
|
60
|
+
schemaDefinitions: readerSteeringSchemaDefinitions,
|
|
61
|
+
publicSettings: (config) => ({ contract: readerSteeringManifest.contract, dimensions: config.dimensions }),
|
|
62
|
+
wire(backend, config, context: PluginWiringContext) {
|
|
63
|
+
const lambda = backend.readerActions.resources.lambda as LambdaFunction;
|
|
64
|
+
const tables = backend.data.resources.tables as Record<string, ITable>;
|
|
65
|
+
const contentActions = context.contentActionsLambda;
|
|
66
|
+
|
|
67
|
+
for (const [variable, model] of [
|
|
68
|
+
["PAPYRUS_READER_PROFILE_TABLE", "ReaderProfile"],
|
|
69
|
+
["PAPYRUS_READER_HANDLE_TABLE", "ReaderHandle"],
|
|
70
|
+
["PAPYRUS_READER_VOTE_TABLE", "ReaderVote"],
|
|
71
|
+
] as const) {
|
|
72
|
+
tables[model].grantReadWriteData(lambda);
|
|
73
|
+
lambda.addEnvironment(variable, tables[model].tableName);
|
|
74
|
+
contentActions.addEnvironment(variable, tables[model].tableName);
|
|
75
|
+
}
|
|
76
|
+
tables.PublishedItem.grantReadData(lambda);
|
|
77
|
+
lambda.addEnvironment("PAPYRUS_READER_PUBLISHED_ITEM_TABLE", tables.PublishedItem.tableName);
|
|
78
|
+
tables.ReaderVote.grantReadData(contentActions);
|
|
79
|
+
tables.ReaderProfile.grantReadWriteData(contentActions);
|
|
80
|
+
|
|
81
|
+
lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
|
|
82
|
+
if (config.reservedConcurrency !== undefined) {
|
|
83
|
+
(lambda.node.defaultChild as CfnFunction).addPropertyOverride("ReservedConcurrentExecutions", config.reservedConcurrency);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Trailing-edge debounce: the first write in a window creates a one-off EventBridge Scheduler schedule at the end of
|
|
87
|
+
// the window (docs/reader-steering.md). The schedule invokes content-actions as this role; reader-actions may create
|
|
88
|
+
// schedules named for this feature, and pass exactly this role.
|
|
89
|
+
const schedulerRole = new Role(Stack.of(lambda), "ReaderSnapshotSchedulerRole", {
|
|
90
|
+
assumedBy: new ServicePrincipal("scheduler.amazonaws.com"),
|
|
91
|
+
description: "Lets one-off schedules created by reader-actions invoke the reader-snapshot run.",
|
|
92
|
+
});
|
|
93
|
+
contentActions.grantInvoke(schedulerRole);
|
|
94
|
+
const stack = Stack.of(lambda); // the role lives with the function, so the stacks keep their existing one-way dependency
|
|
95
|
+
lambda.addToRolePolicy(
|
|
96
|
+
new PolicyStatement({
|
|
97
|
+
effect: Effect.ALLOW,
|
|
98
|
+
actions: ["scheduler:CreateSchedule"],
|
|
99
|
+
resources: [`arn:${stack.partition}:scheduler:${stack.region}:${stack.account}:schedule/default/${SNAPSHOT_SCHEDULE_PREFIX}*`],
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
lambda.addToRolePolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ["iam:PassRole"], resources: [schedulerRole.roleArn] }));
|
|
103
|
+
lambda.addEnvironment("PAPYRUS_READER_SNAPSHOT_FUNCTION_ARN", contentActions.functionArn);
|
|
104
|
+
lambda.addEnvironment("PAPYRUS_READER_SCHEDULER_ROLE_ARN", schedulerRole.roleArn);
|
|
105
|
+
contentActions.addEnvironment("PAPYRUS_READER_SNAPSHOT_SETTINGS", JSON.stringify(readerSnapshotSettings(config)));
|
|
106
|
+
|
|
107
|
+
lambda.addPermission("InvokeFromCmsCompute", {
|
|
108
|
+
principal: new ArnPrincipal(config.computeRoleArn),
|
|
109
|
+
action: "lambda:InvokeFunction",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (config.snapshot.schedule) {
|
|
113
|
+
new Rule(context.createStack("reader-steering-schedule"), "ReaderSnapshotSchedule", {
|
|
114
|
+
description: "Rebuilds the reader snapshot (time decay, Rising) and polls nothing else.",
|
|
115
|
+
schedule: Schedule.rate(Duration.hours(config.snapshot.intervalHours)),
|
|
116
|
+
targets: [new LambdaFunctionTarget(contentActions, { event: RuleTargetInput.fromObject(READER_SNAPSHOT_TASK), retryAttempts: 0 })],
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
backend.addOutput({ custom: { [READER_STEERING_OUTPUT_KEY]: { actionsFunction: lambda.functionName } } });
|
|
121
|
+
},
|
|
122
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { a } from "@aws-amplify/backend";
|
|
2
|
+
/**
|
|
3
|
+
* Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
|
|
4
|
+
* tables directly with its own role, and editors may list them; admins may delete. `id` is the lookup key:
|
|
5
|
+
* the siteSubject for a profile, the lowercase handle for a claim and `subjectKey#readerId#dimension` for a vote.
|
|
6
|
+
*/
|
|
7
|
+
const staffOnly = (allow) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
|
|
8
|
+
export function readerSteeringSchemaDefinitions() {
|
|
9
|
+
return {
|
|
10
|
+
ReaderProfile: a
|
|
11
|
+
.model({
|
|
12
|
+
id: a.id().required(),
|
|
13
|
+
siteSubject: a.string().required(),
|
|
14
|
+
readerId: a.string().required(),
|
|
15
|
+
handle: a.string(),
|
|
16
|
+
state: a.string().required(),
|
|
17
|
+
karma: a.integer(),
|
|
18
|
+
})
|
|
19
|
+
.disableOperations(["subscriptions"])
|
|
20
|
+
.authorization(staffOnly),
|
|
21
|
+
ReaderHandle: a
|
|
22
|
+
.model({
|
|
23
|
+
id: a.id().required(),
|
|
24
|
+
readerId: a.string().required(),
|
|
25
|
+
})
|
|
26
|
+
.disableOperations(["subscriptions"])
|
|
27
|
+
.authorization(staffOnly),
|
|
28
|
+
ReaderVote: a
|
|
29
|
+
.model({
|
|
30
|
+
id: a.id().required(),
|
|
31
|
+
subjectKey: a.string().required(),
|
|
32
|
+
readerId: a.string().required(),
|
|
33
|
+
dimension: a.string().required(),
|
|
34
|
+
value: a.integer().required(),
|
|
35
|
+
voterRole: a.string().required(),
|
|
36
|
+
})
|
|
37
|
+
.disableOperations(["subscriptions"])
|
|
38
|
+
.authorization(staffOnly),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { a } from "@aws-amplify/backend";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
|
|
5
|
+
* tables directly with its own role, and editors may list them; admins may delete. `id` is the lookup key:
|
|
6
|
+
* the siteSubject for a profile, the lowercase handle for a claim and `subjectKey#readerId#dimension` for a vote.
|
|
7
|
+
*/
|
|
8
|
+
const staffOnly = (allow: any) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
|
|
9
|
+
|
|
10
|
+
export function readerSteeringSchemaDefinitions() {
|
|
11
|
+
return {
|
|
12
|
+
ReaderProfile: a
|
|
13
|
+
.model({
|
|
14
|
+
id: a.id().required(),
|
|
15
|
+
siteSubject: a.string().required(),
|
|
16
|
+
readerId: a.string().required(),
|
|
17
|
+
handle: a.string(),
|
|
18
|
+
state: a.string().required(),
|
|
19
|
+
karma: a.integer(),
|
|
20
|
+
})
|
|
21
|
+
.disableOperations(["subscriptions"])
|
|
22
|
+
.authorization(staffOnly),
|
|
23
|
+
ReaderHandle: a
|
|
24
|
+
.model({
|
|
25
|
+
id: a.id().required(),
|
|
26
|
+
readerId: a.string().required(),
|
|
27
|
+
})
|
|
28
|
+
.disableOperations(["subscriptions"])
|
|
29
|
+
.authorization(staffOnly),
|
|
30
|
+
ReaderVote: a
|
|
31
|
+
.model({
|
|
32
|
+
id: a.id().required(),
|
|
33
|
+
subjectKey: a.string().required(),
|
|
34
|
+
readerId: a.string().required(),
|
|
35
|
+
dimension: a.string().required(),
|
|
36
|
+
value: a.integer().required(),
|
|
37
|
+
voterRole: a.string().required(),
|
|
38
|
+
})
|
|
39
|
+
.disableOperations(["subscriptions"])
|
|
40
|
+
.authorization(staffOnly),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { readerSteeringPlugin } from "./reader-steering/papyrus.plugin.js";
|
|
1
2
|
import { signupPlugin } from "./signup/papyrus.plugin.js";
|
|
2
3
|
/** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
|
|
3
4
|
export const KNOWN_PLUGINS = {
|
|
5
|
+
"reader-steering": readerSteeringPlugin,
|
|
4
6
|
signup: signupPlugin,
|
|
5
7
|
};
|
|
6
8
|
export function resolvePlugins(requests) {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { PapyrusPlugin, PluginRequest } from "./types";
|
|
2
|
+
import { readerSteeringPlugin } from "./reader-steering/papyrus.plugin";
|
|
2
3
|
import { signupPlugin } from "./signup/papyrus.plugin";
|
|
3
4
|
|
|
4
5
|
/** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
|
|
5
6
|
export const KNOWN_PLUGINS: Record<string, PapyrusPlugin<any, any>> = {
|
|
7
|
+
"reader-steering": readerSteeringPlugin,
|
|
6
8
|
signup: signupPlugin,
|
|
7
9
|
};
|
|
8
10
|
|
package/amplify/plugins/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
|
|
1
2
|
import type { Construct } from "constructs";
|
|
2
3
|
|
|
3
4
|
/** How a site asks for a plugin in `papyrus.config.ts`: `plugins: ["signup"]` or `plugins: [{ name: "signup", forms: ["news"] }]`. */
|
|
@@ -22,6 +23,8 @@ export type PluginWiringContext = {
|
|
|
22
23
|
createStack: (name: string) => Construct;
|
|
23
24
|
graphqlApiId: string;
|
|
24
25
|
graphqlEndpoint: string;
|
|
26
|
+
/** The site's `content-actions` function: it runs the projection tasks a plugin may add (for example `reader-snapshot`). */
|
|
27
|
+
contentActionsLambda: LambdaFunction;
|
|
25
28
|
};
|
|
26
29
|
|
|
27
30
|
export type PapyrusPlugin<Config, Functions = unknown> = {
|
package/amplify/plugins/wire.js
CHANGED
|
@@ -12,6 +12,7 @@ export function wirePlugins(backend, plugins, contentActionsLambda) {
|
|
|
12
12
|
createStack: (name) => backend.createStack(name),
|
|
13
13
|
graphqlApiId: graphqlApi.apiId,
|
|
14
14
|
graphqlEndpoint: backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl,
|
|
15
|
+
contentActionsLambda,
|
|
15
16
|
};
|
|
16
17
|
for (const { plugin, config } of plugins)
|
|
17
18
|
plugin.wire(backend, config, context);
|
package/amplify/plugins/wire.ts
CHANGED
|
@@ -15,6 +15,7 @@ export function wirePlugins(backend: any, plugins: ResolvedPlugin[], contentActi
|
|
|
15
15
|
createStack: (name: string) => backend.createStack(name),
|
|
16
16
|
graphqlApiId: graphqlApi.apiId as string,
|
|
17
17
|
graphqlEndpoint: backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string,
|
|
18
|
+
contentActionsLambda,
|
|
18
19
|
};
|
|
19
20
|
for (const { plugin, config } of plugins) plugin.wire(backend, config, context);
|
|
20
21
|
contentActionsLambda.addEnvironment(PLUGINS_ENVIRONMENT_VARIABLE, plugins.map(({ plugin }) => plugin.manifest.name).join(","));
|
|
@@ -12,6 +12,9 @@ async function lookUpRedirectTarget(uri) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function rewriteToIndexDocument(uri) {
|
|
15
|
+
if (uri === "/404") {
|
|
16
|
+
return "/404.html";
|
|
17
|
+
}
|
|
15
18
|
if (uri.endsWith("/")) {
|
|
16
19
|
return uri + "index.html";
|
|
17
20
|
}
|
|
@@ -22,6 +25,88 @@ function rewriteToIndexDocument(uri) {
|
|
|
22
25
|
return uri;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
// Pattern redirects: data from the site's projection.json, spliced between the markers at deploy
|
|
29
|
+
// time by redirect-rules.ts. Each rule is {p: regex, t: target with $1..$9, s: status, x: excluded paths}.
|
|
30
|
+
const patternRules = /*PAPYRUS_PATTERN_RULES*/[]/*END_PAPYRUS_PATTERN_RULES*/;
|
|
31
|
+
|
|
32
|
+
function lookUpPatternRedirect(uri) {
|
|
33
|
+
for (let index = 0; index < patternRules.length; index += 1) {
|
|
34
|
+
const rule = patternRules[index];
|
|
35
|
+
if (rule.x.indexOf(uri) !== -1) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const match = new RegExp(rule.p).exec(uri);
|
|
39
|
+
if (match !== null) {
|
|
40
|
+
return {
|
|
41
|
+
status: rule.s,
|
|
42
|
+
target: rule.t.replace(/\$([0-9])/g, function (whole, group) {
|
|
43
|
+
return match[Number(group)] || "";
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Content negotiation for agents: when the site publishes Markdown twins (standard front end, agents.markdown), a request
|
|
52
|
+
// whose Accept header prefers text/markdown over text/html is rewritten to the .md object before the cache lookup. The
|
|
53
|
+
// flag is spliced in by redirect-rules.ts at deploy time.
|
|
54
|
+
const negotiateMarkdown = /*PAPYRUS_NEGOTIATE_MARKDOWN*/false/*END_PAPYRUS_NEGOTIATE_MARKDOWN*/;
|
|
55
|
+
|
|
56
|
+
function qualityOf(accept, mediaType) {
|
|
57
|
+
let quality = 0;
|
|
58
|
+
const parts = accept.toLowerCase().split(",");
|
|
59
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
60
|
+
const fields = parts[index].split(";");
|
|
61
|
+
if (fields[0].trim() !== mediaType) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
quality = 1;
|
|
65
|
+
for (let field = 1; field < fields.length; field += 1) {
|
|
66
|
+
const pair = fields[field].trim();
|
|
67
|
+
if (pair.indexOf("q=") === 0) {
|
|
68
|
+
quality = Number(pair.substring(2));
|
|
69
|
+
if (isNaN(quality)) {
|
|
70
|
+
quality = 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return quality;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function prefersMarkdown(request) {
|
|
79
|
+
const header = request.headers && request.headers.accept;
|
|
80
|
+
if (!header || !header.value) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const markdown = qualityOf(header.value, "text/markdown");
|
|
84
|
+
return markdown > 0 && markdown > qualityOf(header.value, "text/html");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function markdownTwin(uri) {
|
|
88
|
+
if (uri === "/404.html" || uri.length < 6 || uri.substring(uri.length - 5) !== ".html") {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return uri.substring(0, uri.length - 5) + ".md";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Directory-style sites keep a page's twin at /x/index.md, so a bare /x.md is rewritten there. The edge cannot test for
|
|
95
|
+
// existence, hence the rule: a last segment <name>.md with a name other than index. A path prefix (a base path) is just
|
|
96
|
+
// part of <path>. The flag is spliced in by redirect-rules.ts at deploy time from urls.style.
|
|
97
|
+
const directoryTwins = /*PAPYRUS_DIRECTORY_TWINS*/false/*END_PAPYRUS_DIRECTORY_TWINS*/;
|
|
98
|
+
|
|
99
|
+
function directoryTwin(uri) {
|
|
100
|
+
if (uri.length < 5 || uri.substring(uri.length - 3) !== ".md") {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const lastSegment = uri.substring(uri.lastIndexOf("/") + 1);
|
|
104
|
+
if (lastSegment === "index.md" || lastSegment === ".md") {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return uri.substring(0, uri.length - 3) + "/index.md";
|
|
108
|
+
}
|
|
109
|
+
|
|
25
110
|
async function handler(event) {
|
|
26
111
|
const request = event.request;
|
|
27
112
|
const redirectTarget = await lookUpRedirectTarget(request.uri);
|
|
@@ -32,6 +117,26 @@ async function handler(event) {
|
|
|
32
117
|
headers: { location: { value: redirectTarget } },
|
|
33
118
|
};
|
|
34
119
|
}
|
|
120
|
+
const patternRedirect = lookUpPatternRedirect(request.uri);
|
|
121
|
+
if (patternRedirect !== null) {
|
|
122
|
+
return {
|
|
123
|
+
statusCode: patternRedirect.status,
|
|
124
|
+
statusDescription: "Moved",
|
|
125
|
+
headers: { location: { value: patternRedirect.target } },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
35
128
|
request.uri = rewriteToIndexDocument(request.uri);
|
|
129
|
+
if (directoryTwins) {
|
|
130
|
+
const bareTwin = directoryTwin(request.uri);
|
|
131
|
+
if (bareTwin !== null) {
|
|
132
|
+
request.uri = bareTwin;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (negotiateMarkdown && prefersMarkdown(request)) {
|
|
136
|
+
const twin = markdownTwin(request.uri);
|
|
137
|
+
if (twin !== null) {
|
|
138
|
+
request.uri = twin;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
36
141
|
return request;
|
|
37
142
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
/** Mirrors DIRECTORY_HTML_PATTERN and DIRECTORY_SLASH_PATTERN in standard/config.py. */
|
|
4
|
+
const DIRECTORY_HTML_PATTERN = "^/(?!(?:.*/)?index\\.html$)(.+)\\.html$";
|
|
5
|
+
const DIRECTORY_SLASH_PATTERN = "^/((?:[^/]+/)*[^/.]+)$";
|
|
6
|
+
const START_MARKER = "/*PAPYRUS_PATTERN_RULES*/";
|
|
7
|
+
const END_MARKER = "/*END_PAPYRUS_PATTERN_RULES*/";
|
|
8
|
+
/**
|
|
9
|
+
* The pattern rules a site declares in projection.json: its `redirects.patterns`, plus, for
|
|
10
|
+
* `urls.style: "directory"`, `/x.html` to `/x/`. Mirrors `StandardSettings.edge_rules` in Python.
|
|
11
|
+
*/
|
|
12
|
+
export function edgeRulesFromSettings(settings) {
|
|
13
|
+
const rules = (settings.redirects?.patterns ?? []).map((rule) => ({
|
|
14
|
+
p: rule.pattern,
|
|
15
|
+
t: rule.to,
|
|
16
|
+
s: rule.status ?? 301,
|
|
17
|
+
x: rule.except ?? [],
|
|
18
|
+
}));
|
|
19
|
+
if (settings.urls?.style === "directory") {
|
|
20
|
+
// /x.html to /x/ (a directory index file such as /x/index.html is served as it is), then /x to /x/.
|
|
21
|
+
rules.push({ p: DIRECTORY_HTML_PATTERN, t: "/$1/", s: 301, x: ["/404.html"] });
|
|
22
|
+
rules.push({ p: DIRECTORY_SLASH_PATTERN, t: "/$1/", s: 301, x: ["/404"] });
|
|
23
|
+
}
|
|
24
|
+
return rules;
|
|
25
|
+
}
|
|
26
|
+
/** A standard-front-end site publishes Markdown twins unless it turns `agents.markdown` off. */
|
|
27
|
+
export function negotiatesMarkdown(settings) {
|
|
28
|
+
return settings.site !== undefined && settings.agents?.markdown !== false;
|
|
29
|
+
}
|
|
30
|
+
/** A directory-style site keeps a page's Markdown twin at `/x/index.md`; the edge then serves a bare `/x.md` from there. */
|
|
31
|
+
export function usesDirectoryTwins(settings) {
|
|
32
|
+
return negotiatesMarkdown(settings) && settings.urls?.style === "directory";
|
|
33
|
+
}
|
|
34
|
+
export function readEdgeConfig(staticDir, baseDirectory = process.cwd()) {
|
|
35
|
+
if (staticDir === undefined)
|
|
36
|
+
return { rules: [], negotiateMarkdown: false, directoryTwins: false };
|
|
37
|
+
const settingsPath = resolve(baseDirectory, staticDir, "projection.json");
|
|
38
|
+
if (!existsSync(settingsPath))
|
|
39
|
+
return { rules: [], negotiateMarkdown: false, directoryTwins: false };
|
|
40
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
41
|
+
return { rules: edgeRulesFromSettings(settings), negotiateMarkdown: negotiatesMarkdown(settings), directoryTwins: usesDirectoryTwins(settings) };
|
|
42
|
+
}
|
|
43
|
+
export function readEdgeRules(staticDir, baseDirectory = process.cwd()) {
|
|
44
|
+
return readEdgeConfig(staticDir, baseDirectory).rules;
|
|
45
|
+
}
|
|
46
|
+
const NEGOTIATE_START = "/*PAPYRUS_NEGOTIATE_MARKDOWN*/";
|
|
47
|
+
const NEGOTIATE_END = "/*END_PAPYRUS_NEGOTIATE_MARKDOWN*/";
|
|
48
|
+
const TWINS_START = "/*PAPYRUS_DIRECTORY_TWINS*/";
|
|
49
|
+
const TWINS_END = "/*END_PAPYRUS_DIRECTORY_TWINS*/";
|
|
50
|
+
function spliceFlag(source, start, end, label) {
|
|
51
|
+
const from = source.indexOf(start);
|
|
52
|
+
const to = source.indexOf(end);
|
|
53
|
+
if (from === -1 || to === -1 || to < from) {
|
|
54
|
+
throw new Error(`redirect function source has no ${label} markers`);
|
|
55
|
+
}
|
|
56
|
+
return source.slice(0, from + start.length) + "true" + source.slice(to);
|
|
57
|
+
}
|
|
58
|
+
/** The CloudFront Function source with the site's pattern rules and Markdown negotiation flag spliced in. */
|
|
59
|
+
export function compileRedirectFunctionSource(source, rules, options = {}) {
|
|
60
|
+
const start = source.indexOf(START_MARKER);
|
|
61
|
+
const end = source.indexOf(END_MARKER);
|
|
62
|
+
if (start === -1 || end === -1 || end < start) {
|
|
63
|
+
throw new Error("redirect function source has no pattern-rule markers");
|
|
64
|
+
}
|
|
65
|
+
let compiled = rules.length === 0 ? source : source.slice(0, start + START_MARKER.length) + JSON.stringify(rules) + source.slice(end);
|
|
66
|
+
if (options.negotiateMarkdown === true) {
|
|
67
|
+
compiled = spliceFlag(compiled, NEGOTIATE_START, NEGOTIATE_END, "markdown negotiation");
|
|
68
|
+
}
|
|
69
|
+
if (options.directoryTwins === true) {
|
|
70
|
+
compiled = spliceFlag(compiled, TWINS_START, TWINS_END, "directory twin");
|
|
71
|
+
}
|
|
72
|
+
return compiled;
|
|
73
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** One pattern redirect as the edge function reads it: regex, target with $1..$9, status, excluded paths. */
|
|
5
|
+
export type EdgeRedirectRule = { p: string; t: string; s: number; x: string[] };
|
|
6
|
+
|
|
7
|
+
/** Mirrors DIRECTORY_HTML_PATTERN and DIRECTORY_SLASH_PATTERN in standard/config.py. */
|
|
8
|
+
const DIRECTORY_HTML_PATTERN = "^/(?!(?:.*/)?index\\.html$)(.+)\\.html$";
|
|
9
|
+
const DIRECTORY_SLASH_PATTERN = "^/((?:[^/]+/)*[^/.]+)$";
|
|
10
|
+
|
|
11
|
+
const START_MARKER = "/*PAPYRUS_PATTERN_RULES*/";
|
|
12
|
+
const END_MARKER = "/*END_PAPYRUS_PATTERN_RULES*/";
|
|
13
|
+
|
|
14
|
+
type ProjectionRedirects = { patterns?: { pattern: string; to: string; status?: number; except?: string[] }[] };
|
|
15
|
+
type ProjectionSettings = { site?: unknown; urls?: { style?: string }; redirects?: ProjectionRedirects; agents?: { markdown?: boolean } };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The pattern rules a site declares in projection.json: its `redirects.patterns`, plus, for
|
|
19
|
+
* `urls.style: "directory"`, `/x.html` to `/x/`. Mirrors `StandardSettings.edge_rules` in Python.
|
|
20
|
+
*/
|
|
21
|
+
export function edgeRulesFromSettings(settings: ProjectionSettings): EdgeRedirectRule[] {
|
|
22
|
+
const rules = (settings.redirects?.patterns ?? []).map((rule) => ({
|
|
23
|
+
p: rule.pattern,
|
|
24
|
+
t: rule.to,
|
|
25
|
+
s: rule.status ?? 301,
|
|
26
|
+
x: rule.except ?? [],
|
|
27
|
+
}));
|
|
28
|
+
if (settings.urls?.style === "directory") {
|
|
29
|
+
// /x.html to /x/ (a directory index file such as /x/index.html is served as it is), then /x to /x/.
|
|
30
|
+
rules.push({ p: DIRECTORY_HTML_PATTERN, t: "/$1/", s: 301, x: ["/404.html"] });
|
|
31
|
+
rules.push({ p: DIRECTORY_SLASH_PATTERN, t: "/$1/", s: 301, x: ["/404"] });
|
|
32
|
+
}
|
|
33
|
+
return rules;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A standard-front-end site publishes Markdown twins unless it turns `agents.markdown` off. */
|
|
37
|
+
export function negotiatesMarkdown(settings: ProjectionSettings): boolean {
|
|
38
|
+
return settings.site !== undefined && settings.agents?.markdown !== false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A directory-style site keeps a page's Markdown twin at `/x/index.md`; the edge then serves a bare `/x.md` from there. */
|
|
42
|
+
export function usesDirectoryTwins(settings: ProjectionSettings): boolean {
|
|
43
|
+
return negotiatesMarkdown(settings) && settings.urls?.style === "directory";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type EdgeConfig = { rules: EdgeRedirectRule[]; negotiateMarkdown: boolean; directoryTwins: boolean };
|
|
47
|
+
|
|
48
|
+
export function readEdgeConfig(staticDir: string | undefined, baseDirectory: string = process.cwd()): EdgeConfig {
|
|
49
|
+
if (staticDir === undefined) return { rules: [], negotiateMarkdown: false, directoryTwins: false };
|
|
50
|
+
const settingsPath = resolve(baseDirectory, staticDir, "projection.json");
|
|
51
|
+
if (!existsSync(settingsPath)) return { rules: [], negotiateMarkdown: false, directoryTwins: false };
|
|
52
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as ProjectionSettings;
|
|
53
|
+
return { rules: edgeRulesFromSettings(settings), negotiateMarkdown: negotiatesMarkdown(settings), directoryTwins: usesDirectoryTwins(settings) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readEdgeRules(staticDir: string | undefined, baseDirectory: string = process.cwd()): EdgeRedirectRule[] {
|
|
57
|
+
return readEdgeConfig(staticDir, baseDirectory).rules;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const NEGOTIATE_START = "/*PAPYRUS_NEGOTIATE_MARKDOWN*/";
|
|
61
|
+
const NEGOTIATE_END = "/*END_PAPYRUS_NEGOTIATE_MARKDOWN*/";
|
|
62
|
+
const TWINS_START = "/*PAPYRUS_DIRECTORY_TWINS*/";
|
|
63
|
+
const TWINS_END = "/*END_PAPYRUS_DIRECTORY_TWINS*/";
|
|
64
|
+
|
|
65
|
+
function spliceFlag(source: string, start: string, end: string, label: string): string {
|
|
66
|
+
const from = source.indexOf(start);
|
|
67
|
+
const to = source.indexOf(end);
|
|
68
|
+
if (from === -1 || to === -1 || to < from) {
|
|
69
|
+
throw new Error(`redirect function source has no ${label} markers`);
|
|
70
|
+
}
|
|
71
|
+
return source.slice(0, from + start.length) + "true" + source.slice(to);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The CloudFront Function source with the site's pattern rules and Markdown negotiation flag spliced in. */
|
|
75
|
+
export function compileRedirectFunctionSource(
|
|
76
|
+
source: string,
|
|
77
|
+
rules: EdgeRedirectRule[],
|
|
78
|
+
options: { negotiateMarkdown?: boolean; directoryTwins?: boolean } = {},
|
|
79
|
+
): string {
|
|
80
|
+
const start = source.indexOf(START_MARKER);
|
|
81
|
+
const end = source.indexOf(END_MARKER);
|
|
82
|
+
if (start === -1 || end === -1 || end < start) {
|
|
83
|
+
throw new Error("redirect function source has no pattern-rule markers");
|
|
84
|
+
}
|
|
85
|
+
let compiled = rules.length === 0 ? source : source.slice(0, start + START_MARKER.length) + JSON.stringify(rules) + source.slice(end);
|
|
86
|
+
if (options.negotiateMarkdown === true) {
|
|
87
|
+
compiled = spliceFlag(compiled, NEGOTIATE_START, NEGOTIATE_END, "markdown negotiation");
|
|
88
|
+
}
|
|
89
|
+
if (options.directoryTwins === true) {
|
|
90
|
+
compiled = spliceFlag(compiled, TWINS_START, TWINS_END, "directory twin");
|
|
91
|
+
}
|
|
92
|
+
return compiled;
|
|
93
|
+
}
|