@anthusai/papyrus 2.0.0-next.7 → 2.0.0-next.70
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 +2028 -1979
- package/amplify/data/schema.ts +72 -14
- package/amplify/functions/assignment-action/data-access.js +90 -0
- package/amplify/functions/assignment-action/data-access.ts +89 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +61 -2
- 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/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
- 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 +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +245 -0
- package/amplify/plugins/newsletter/config.ts +305 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +180 -0
- package/amplify/plugins/reader-steering/config.ts +280 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
- package/amplify/plugins/reader-steering/function/handler.py +1554 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/proposals.py +255 -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 +184 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +210 -0
- package/amplify/plugins/reader-steering/schema.js +204 -0
- package/amplify/plugins/reader-steering/schema.ts +206 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/projection-lease.js +30 -0
- package/amplify/publication/projection-lease.ts +39 -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 +38 -1
- package/amplify/site-backend.ts +46 -1
- package/app/api/auth/prospectus/sign-in/route.ts +4 -0
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/state/route.ts +9 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- 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/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/flag/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/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/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 +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-article-editor.tsx +2 -4
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-organizer.css +31 -0
- package/components/newsroom-references-view.tsx +14 -14
- 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 +64 -20
- package/components/use-editorial-checks.ts +12 -10
- 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 +86 -6
- package/infra/site-config.ts +129 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsletter-api.ts +418 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +84 -19
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -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/semantic-graph.ts +2 -0
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +42 -3
- package/routes.manifest.json +215 -2
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -69
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -85
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { a } from "@aws-amplify/backend";
|
|
2
|
+
import { readerActions } from "./function/resource";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Staff and IAM only: no guest, API key or reader rule on any model. The reader-actions function writes the
|
|
6
|
+
* tables directly with its own role, and editors may list them; admins may delete. `id` is the lookup key:
|
|
7
|
+
* the siteSubject for a profile, the lowercase handle for a claim, `subjectKey#readerId#dimension` for a vote (`subjectKey#readerId#kind` for a flag; the key is the bare id for items and submissions and `type:id` for the rest) and
|
|
8
|
+
* `sub-<sha256 of the canonical URL>` for a submission (so a canonical URL is one submission).
|
|
9
|
+
*/
|
|
10
|
+
const staffOnly = (allow: any) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
|
|
11
|
+
|
|
12
|
+
export function readerSteeringSchemaDefinitions() {
|
|
13
|
+
return {
|
|
14
|
+
ReaderProfile: a
|
|
15
|
+
.model({
|
|
16
|
+
id: a.id().required(),
|
|
17
|
+
siteSubject: a.string().required(),
|
|
18
|
+
readerId: a.string().required(),
|
|
19
|
+
handle: a.string(),
|
|
20
|
+
state: a.string().required(),
|
|
21
|
+
karma: a.integer(),
|
|
22
|
+
})
|
|
23
|
+
.disableOperations(["subscriptions"])
|
|
24
|
+
.secondaryIndexes((index) => [index("readerId").name("profilesByReader").queryField("listReaderProfilesByReader")])
|
|
25
|
+
.authorization(staffOnly),
|
|
26
|
+
ReaderHandle: a
|
|
27
|
+
.model({
|
|
28
|
+
id: a.id().required(),
|
|
29
|
+
readerId: a.string().required(),
|
|
30
|
+
})
|
|
31
|
+
.disableOperations(["subscriptions"])
|
|
32
|
+
.authorization(staffOnly),
|
|
33
|
+
ReaderVote: a
|
|
34
|
+
.model({
|
|
35
|
+
id: a.id().required(),
|
|
36
|
+
subjectKey: a.string().required(),
|
|
37
|
+
subjectType: a.string(),
|
|
38
|
+
readerId: a.string().required(),
|
|
39
|
+
dimension: a.string().required(),
|
|
40
|
+
value: a.integer().required(),
|
|
41
|
+
voterRole: a.string().required(),
|
|
42
|
+
})
|
|
43
|
+
.disableOperations(["subscriptions"])
|
|
44
|
+
.authorization(staffOnly),
|
|
45
|
+
ReaderFlag: a
|
|
46
|
+
.model({
|
|
47
|
+
id: a.id().required(),
|
|
48
|
+
subjectKey: a.string().required(),
|
|
49
|
+
subjectType: a.string().required(),
|
|
50
|
+
readerId: a.string().required(),
|
|
51
|
+
kind: a.string().required(),
|
|
52
|
+
reason: a.string().required(),
|
|
53
|
+
targetKey: a.string(),
|
|
54
|
+
suggestedName: a.string(),
|
|
55
|
+
voterRole: a.string().required(),
|
|
56
|
+
createdAt: a.datetime().required(),
|
|
57
|
+
})
|
|
58
|
+
.disableOperations(["subscriptions"])
|
|
59
|
+
.authorization(staffOnly),
|
|
60
|
+
ReaderComment: a
|
|
61
|
+
.model({
|
|
62
|
+
id: a.id().required(),
|
|
63
|
+
subjectKey: a.string().required(),
|
|
64
|
+
readerId: a.string().required(),
|
|
65
|
+
body: a.string().required(),
|
|
66
|
+
parentId: a.string(),
|
|
67
|
+
ratingContext: a.json(),
|
|
68
|
+
state: a.string().required(),
|
|
69
|
+
signals: a.json(),
|
|
70
|
+
moderation: a.string().required(),
|
|
71
|
+
reportCount: a.integer(),
|
|
72
|
+
reviewedAt: a.datetime(),
|
|
73
|
+
editedAt: a.datetime(),
|
|
74
|
+
deletedAt: a.datetime(),
|
|
75
|
+
createdAt: a.datetime().required(),
|
|
76
|
+
})
|
|
77
|
+
.disableOperations(["subscriptions"])
|
|
78
|
+
.secondaryIndexes((index) => [index("moderation").sortKeys(["createdAt"]).name("commentsByModeration").queryField("listReaderCommentsByModerationAndCreatedAt")])
|
|
79
|
+
.authorization(staffOnly),
|
|
80
|
+
ReaderSubmission: a
|
|
81
|
+
.model({
|
|
82
|
+
id: a.id().required(),
|
|
83
|
+
canonicalUrl: a.string().required(),
|
|
84
|
+
domain: a.string().required(),
|
|
85
|
+
title: a.string().required(),
|
|
86
|
+
kind: a.string().required(),
|
|
87
|
+
note: a.string(),
|
|
88
|
+
readerId: a.string().required(),
|
|
89
|
+
state: a.string().required(),
|
|
90
|
+
similar: a.string().array(),
|
|
91
|
+
signals: a.json(),
|
|
92
|
+
extractionState: a.string().required(),
|
|
93
|
+
itemId: a.string(),
|
|
94
|
+
moderation: a.string().required(),
|
|
95
|
+
reportCount: a.integer(),
|
|
96
|
+
reviewedAt: a.datetime(),
|
|
97
|
+
createdAt: a.datetime().required(),
|
|
98
|
+
})
|
|
99
|
+
.disableOperations(["subscriptions"])
|
|
100
|
+
.secondaryIndexes((index) => [
|
|
101
|
+
index("domain").sortKeys(["createdAt"]).name("submissionsByDomain").queryField("listReaderSubmissionsByDomainAndCreatedAt"),
|
|
102
|
+
index("moderation").sortKeys(["createdAt"]).name("submissionsByModeration").queryField("listReaderSubmissionsByModerationAndCreatedAt"),
|
|
103
|
+
])
|
|
104
|
+
.authorization(staffOnly),
|
|
105
|
+
ReaderReport: a
|
|
106
|
+
.model({
|
|
107
|
+
id: a.id().required(),
|
|
108
|
+
targetType: a.string().required(),
|
|
109
|
+
target: a.string().required(),
|
|
110
|
+
reporterReaderId: a.string().required(),
|
|
111
|
+
reason: a.string().required(),
|
|
112
|
+
state: a.string().required(),
|
|
113
|
+
closedAt: a.datetime(),
|
|
114
|
+
createdAt: a.datetime().required(),
|
|
115
|
+
})
|
|
116
|
+
.disableOperations(["subscriptions"])
|
|
117
|
+
.secondaryIndexes((index) => [index("target").sortKeys(["createdAt"]).name("reportsByTarget").queryField("listReaderReportsByTargetAndCreatedAt")])
|
|
118
|
+
.authorization(staffOnly),
|
|
119
|
+
ModerationAction: a
|
|
120
|
+
.model({
|
|
121
|
+
id: a.id().required(),
|
|
122
|
+
by: a.string().required(),
|
|
123
|
+
actorSub: a.string().required(),
|
|
124
|
+
action: a.string().required(),
|
|
125
|
+
targetType: a.string().required(),
|
|
126
|
+
target: a.string().required(),
|
|
127
|
+
reason: a.string(),
|
|
128
|
+
fromState: a.string().required(),
|
|
129
|
+
toState: a.string().required(),
|
|
130
|
+
fromModeration: a.string(),
|
|
131
|
+
toModeration: a.string(),
|
|
132
|
+
undoOf: a.string(),
|
|
133
|
+
undoneBy: a.string(),
|
|
134
|
+
at: a.datetime().required(),
|
|
135
|
+
})
|
|
136
|
+
.disableOperations(["subscriptions"])
|
|
137
|
+
.secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
|
|
138
|
+
.authorization(staffOnly),
|
|
139
|
+
Proposal: a
|
|
140
|
+
.model({
|
|
141
|
+
id: a.id().required(),
|
|
142
|
+
kind: a.string().required(),
|
|
143
|
+
title: a.string().required(),
|
|
144
|
+
summary: a.string(),
|
|
145
|
+
body: a.string().required(),
|
|
146
|
+
targets: a.json().required(),
|
|
147
|
+
evidence: a.json(),
|
|
148
|
+
proposer: a.string().required(),
|
|
149
|
+
state: a.string().required(),
|
|
150
|
+
history: a.json().required(),
|
|
151
|
+
communityResult: a.json(),
|
|
152
|
+
createdAt: a.datetime().required(),
|
|
153
|
+
})
|
|
154
|
+
.disableOperations(["subscriptions"])
|
|
155
|
+
.secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
|
|
156
|
+
.authorization(staffOnly),
|
|
157
|
+
ProposalReceipt: a.customType({
|
|
158
|
+
status: a.string().required(),
|
|
159
|
+
id: a.string(),
|
|
160
|
+
error: a.string(),
|
|
161
|
+
message: a.string(),
|
|
162
|
+
}),
|
|
163
|
+
fileProposal: a
|
|
164
|
+
.mutation()
|
|
165
|
+
.arguments({
|
|
166
|
+
kind: a.string().required(),
|
|
167
|
+
title: a.string().required(),
|
|
168
|
+
body: a.string().required(),
|
|
169
|
+
summary: a.string(),
|
|
170
|
+
targets: a.json(),
|
|
171
|
+
evidence: a.json(),
|
|
172
|
+
})
|
|
173
|
+
.returns(a.ref("ProposalReceipt"))
|
|
174
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
175
|
+
.handler(a.handler.function(readerActions)),
|
|
176
|
+
transitionProposal: a
|
|
177
|
+
.mutation()
|
|
178
|
+
.arguments({
|
|
179
|
+
proposalId: a.string().required(),
|
|
180
|
+
state: a.string().required(),
|
|
181
|
+
reason: a.string(),
|
|
182
|
+
result: a.string(),
|
|
183
|
+
})
|
|
184
|
+
.returns(a.ref("ProposalReceipt"))
|
|
185
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
186
|
+
.handler(a.handler.function(readerActions)),
|
|
187
|
+
ModerationReceipt: a.customType({
|
|
188
|
+
status: a.string().required(),
|
|
189
|
+
id: a.string(),
|
|
190
|
+
error: a.string(),
|
|
191
|
+
message: a.string(),
|
|
192
|
+
}),
|
|
193
|
+
moderateReader: a
|
|
194
|
+
.mutation()
|
|
195
|
+
.arguments({
|
|
196
|
+
action: a.string().required(),
|
|
197
|
+
targetType: a.string().required(),
|
|
198
|
+
target: a.string().required(),
|
|
199
|
+
reason: a.string(),
|
|
200
|
+
undoOf: a.string(),
|
|
201
|
+
})
|
|
202
|
+
.returns(a.ref("ModerationReceipt"))
|
|
203
|
+
.authorization((allow) => [allow.group("editor"), allow.group("admin")])
|
|
204
|
+
.handler(a.handler.function(readerActions)),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { newsletterPlugin } from "./newsletter/papyrus.plugin.js";
|
|
2
|
+
import { readerSteeringPlugin } from "./reader-steering/papyrus.plugin.js";
|
|
2
3
|
import { signupPlugin } from "./signup/papyrus.plugin.js";
|
|
3
4
|
/** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
|
|
4
5
|
export const KNOWN_PLUGINS = {
|
|
5
|
-
|
|
6
|
+
newsletter: newsletterPlugin,
|
|
7
|
+
"reader-steering": readerSteeringPlugin,
|
|
6
8
|
signup: signupPlugin,
|
|
7
9
|
};
|
|
8
10
|
export function resolvePlugins(requests) {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { PapyrusPlugin, PluginRequest } from "./types";
|
|
2
|
-
import {
|
|
2
|
+
import { newsletterPlugin } from "./newsletter/papyrus.plugin";
|
|
3
|
+
import { readerSteeringPlugin } from "./reader-steering/papyrus.plugin";
|
|
3
4
|
import { signupPlugin } from "./signup/papyrus.plugin";
|
|
4
5
|
|
|
5
6
|
/** Plugins are packaged with Papyrus and pinned by its version: there is no registry to fetch from. */
|
|
6
7
|
export const KNOWN_PLUGINS: Record<string, PapyrusPlugin<any, any>> = {
|
|
7
|
-
|
|
8
|
+
newsletter: newsletterPlugin,
|
|
9
|
+
"reader-steering": readerSteeringPlugin,
|
|
8
10
|
signup: signupPlugin,
|
|
9
11
|
};
|
|
10
12
|
|
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"] }]`. */
|
|
@@ -9,6 +10,8 @@ export type PluginManifest = {
|
|
|
9
10
|
/** Public contract version. Bump it when a mutation input, a model field, an element name or an attribute changes. No compatibility shims. */
|
|
10
11
|
contract: number;
|
|
11
12
|
models: string[];
|
|
13
|
+
/** Custom queries it adds (operations that read). */
|
|
14
|
+
queries?: string[];
|
|
12
15
|
mutations: string[];
|
|
13
16
|
/** What an anonymous visitor may call. Everything else is closed to guests. */
|
|
14
17
|
guest: { mutations: string[] };
|
|
@@ -22,6 +25,8 @@ export type PluginWiringContext = {
|
|
|
22
25
|
createStack: (name: string) => Construct;
|
|
23
26
|
graphqlApiId: string;
|
|
24
27
|
graphqlEndpoint: string;
|
|
28
|
+
/** The site's `content-actions` function: it runs the projection tasks a plugin may add (for example `reader-snapshot`). */
|
|
29
|
+
contentActionsLambda: LambdaFunction;
|
|
25
30
|
};
|
|
26
31
|
|
|
27
32
|
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(","));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Stack } from "aws-cdk-lib";
|
|
2
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
3
|
+
/** The shared lock table's name (created by the cloud knowledge run stack). The Lambda finds it through this SSM parameter. */
|
|
4
|
+
export const PROJECTION_LOCK_TABLE_PARAMETER = "/papyrus/projection-lock/table-name";
|
|
5
|
+
export const PROJECTION_LOCK_TABLE_NAME = "papyrus-projection-locks";
|
|
6
|
+
export const PROJECTION_LOCK_TABLE_ACTIONS = ["dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:UpdateItem", "dynamodb:GetItem"];
|
|
7
|
+
export function projectionLeaseParameterArn(region, account) {
|
|
8
|
+
return `arn:aws:ssm:${region}:${account}:parameter${PROJECTION_LOCK_TABLE_PARAMETER}`;
|
|
9
|
+
}
|
|
10
|
+
export function projectionLeaseTableArn(region, account) {
|
|
11
|
+
return `arn:aws:dynamodb:${region}:${account}:table/${PROJECTION_LOCK_TABLE_NAME}*`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Let a projecting Lambda take and release the per-publication projection lease: read the table-name
|
|
15
|
+
* parameter and write items in the lock table, nothing broader. The table itself is not created here;
|
|
16
|
+
* a deployment without it keeps projecting without a lease.
|
|
17
|
+
*/
|
|
18
|
+
export function addProjectionLeaseAccess(lambda) {
|
|
19
|
+
const stack = Stack.of(lambda);
|
|
20
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
21
|
+
effect: Effect.ALLOW,
|
|
22
|
+
actions: ["ssm:GetParameter"],
|
|
23
|
+
resources: [projectionLeaseParameterArn(stack.region, stack.account)],
|
|
24
|
+
}));
|
|
25
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
26
|
+
effect: Effect.ALLOW,
|
|
27
|
+
actions: PROJECTION_LOCK_TABLE_ACTIONS,
|
|
28
|
+
resources: [projectionLeaseTableArn(stack.region, stack.account)],
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Stack } from "aws-cdk-lib";
|
|
2
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
3
|
+
import type { IFunction } from "aws-cdk-lib/aws-lambda";
|
|
4
|
+
|
|
5
|
+
/** The shared lock table's name (created by the cloud knowledge run stack). The Lambda finds it through this SSM parameter. */
|
|
6
|
+
export const PROJECTION_LOCK_TABLE_PARAMETER = "/papyrus/projection-lock/table-name";
|
|
7
|
+
export const PROJECTION_LOCK_TABLE_NAME = "papyrus-projection-locks";
|
|
8
|
+
export const PROJECTION_LOCK_TABLE_ACTIONS = ["dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:UpdateItem", "dynamodb:GetItem"];
|
|
9
|
+
|
|
10
|
+
export function projectionLeaseParameterArn(region: string, account: string): string {
|
|
11
|
+
return `arn:aws:ssm:${region}:${account}:parameter${PROJECTION_LOCK_TABLE_PARAMETER}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function projectionLeaseTableArn(region: string, account: string): string {
|
|
15
|
+
return `arn:aws:dynamodb:${region}:${account}:table/${PROJECTION_LOCK_TABLE_NAME}*`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Let a projecting Lambda take and release the per-publication projection lease: read the table-name
|
|
20
|
+
* parameter and write items in the lock table, nothing broader. The table itself is not created here;
|
|
21
|
+
* a deployment without it keeps projecting without a lease.
|
|
22
|
+
*/
|
|
23
|
+
export function addProjectionLeaseAccess(lambda: IFunction): void {
|
|
24
|
+
const stack = Stack.of(lambda);
|
|
25
|
+
lambda.addToRolePolicy(
|
|
26
|
+
new PolicyStatement({
|
|
27
|
+
effect: Effect.ALLOW,
|
|
28
|
+
actions: ["ssm:GetParameter"],
|
|
29
|
+
resources: [projectionLeaseParameterArn(stack.region, stack.account)],
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
lambda.addToRolePolicy(
|
|
33
|
+
new PolicyStatement({
|
|
34
|
+
effect: Effect.ALLOW,
|
|
35
|
+
actions: PROJECTION_LOCK_TABLE_ACTIONS,
|
|
36
|
+
resources: [projectionLeaseTableArn(stack.region, stack.account)],
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -7,11 +7,13 @@ import { SqsDestination } from "aws-cdk-lib/aws-lambda-destinations";
|
|
|
7
7
|
import * as route53 from "aws-cdk-lib/aws-route53";
|
|
8
8
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
9
9
|
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
10
11
|
import { dirname, resolve } from "node:path";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { assertValidPublicationConfig } from "../site-backend-config.js";
|
|
13
14
|
import { addRenditionsFunction } from "./renditions-function.js";
|
|
14
15
|
import { RENDITIONS_FUNCTION_NAME_ENV } from "./renditions-settings.js";
|
|
16
|
+
import { compileRedirectFunctionSource, readEdgeConfig } from "./redirect-rules.js";
|
|
15
17
|
import { addInvalidationPathAlarm } from "./invalidation-alarm.js";
|
|
16
18
|
import { distributionIdFromArn, s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
|
|
17
19
|
const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
|
|
@@ -68,9 +70,13 @@ export function addS3ProjectionPublication(scope, props, media) {
|
|
|
68
70
|
assertValidPublicationConfig({ kind: "s3-projection", ...props });
|
|
69
71
|
const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
|
|
70
72
|
const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
|
|
73
|
+
const edgeConfig = readEdgeConfig(props.staticDir);
|
|
71
74
|
const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
|
|
72
75
|
runtime: cloudfront.FunctionRuntime.JS_2_0,
|
|
73
|
-
code: cloudfront.FunctionCode.
|
|
76
|
+
code: cloudfront.FunctionCode.fromInline(compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), edgeConfig.rules, {
|
|
77
|
+
negotiateMarkdown: edgeConfig.negotiateMarkdown,
|
|
78
|
+
directoryTwins: edgeConfig.directoryTwins,
|
|
79
|
+
})),
|
|
74
80
|
keyValueStore,
|
|
75
81
|
});
|
|
76
82
|
const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
|