@unitninety/site-kit 0.0.0-stage → 1.6.1
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/README.md +12 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1010 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +271 -0
- package/src/bridge/registry.ts +27 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +12 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
|
|
2
|
+
import { isIP } from 'node:net'
|
|
3
|
+
|
|
4
|
+
const MAX_TOKEN_BYTES = 8192
|
|
5
|
+
const CERTIFICATE_PREFIX = 'u90tc1'
|
|
6
|
+
const SESSION_PREFIX = 'u90tp1'
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
8
|
+
const CERTIFICATE_FIELDS = ['version', 'environmentId', 'platformOrigin', 'sourceOrigin', 'publicKey', 'issuedAt', 'expiresAt']
|
|
9
|
+
const SESSION_FIELDS = ['version', 'purpose', 'tenantPublicId', 'nonce', 'issuedAt', 'expiresAt']
|
|
10
|
+
|
|
11
|
+
function invalid() {
|
|
12
|
+
throw new Error('Invalid test preview credential')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Prevent crypto/parser errors from exposing supplied credential or key material.
|
|
16
|
+
function bounded(operation) {
|
|
17
|
+
try {
|
|
18
|
+
return operation()
|
|
19
|
+
} catch {
|
|
20
|
+
return invalid()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function record(value, fields) {
|
|
25
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid()
|
|
26
|
+
const keys = Object.keys(value)
|
|
27
|
+
if (keys.length !== fields.length || fields.some(field => !Object.hasOwn(value, field))) invalid()
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shortString(value) {
|
|
32
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > 256 || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) invalid()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function integer(value) {
|
|
36
|
+
if (!Number.isSafeInteger(value) || value < 0) invalid()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function lifetime(value, maximum, now) {
|
|
40
|
+
integer(value.issuedAt)
|
|
41
|
+
integer(value.expiresAt)
|
|
42
|
+
if (value.expiresAt <= value.issuedAt || value.expiresAt - value.issuedAt > maximum) invalid()
|
|
43
|
+
if (now !== undefined) {
|
|
44
|
+
integer(now)
|
|
45
|
+
if (value.issuedAt > now || value.expiresAt <= now) invalid()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function privateIPv4(address) {
|
|
50
|
+
const [a, b] = address.split('.').map(Number)
|
|
51
|
+
return a === 0 || a === 10 || a === 127 || a >= 224 ||
|
|
52
|
+
(a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
|
|
53
|
+
(a === 172 && b >= 16 && b <= 31) || (a === 192 && (b === 168 || b === 0)) ||
|
|
54
|
+
(a === 198 && (b === 18 || b === 19))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function privateIPv6(address) {
|
|
58
|
+
const halves = address.split('::')
|
|
59
|
+
const left = halves[0] ? halves[0].split(':') : []
|
|
60
|
+
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : []
|
|
61
|
+
const groups = [...left, ...Array(8 - left.length - right.length).fill('0'), ...right].map(group => parseInt(group, 16))
|
|
62
|
+
// URL canonicalization renders IPv4-mapped literals in hexadecimal form.
|
|
63
|
+
if (groups.slice(0, 5).every(group => group === 0) && (groups[5] === 0xffff || groups[5] === 0)) {
|
|
64
|
+
return privateIPv4(`${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}`)
|
|
65
|
+
}
|
|
66
|
+
return (groups[0] & 0xfe00) === 0xfc00 || (groups[0] & 0xffc0) === 0xfe80 ||
|
|
67
|
+
(groups[0] & 0xffc0) === 0xfec0 || (groups[0] & 0xff00) === 0xff00
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function origin(value) {
|
|
71
|
+
if (typeof value !== 'string' || value.length > 2048) invalid()
|
|
72
|
+
const url = new URL(value)
|
|
73
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.origin !== value) invalid()
|
|
74
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '')
|
|
75
|
+
const localName = hostname.replace(/\.$/, '')
|
|
76
|
+
if (localName === 'localhost' || localName.endsWith('.localhost')) invalid()
|
|
77
|
+
const family = isIP(hostname)
|
|
78
|
+
if ((family === 4 && privateIPv4(hostname)) || (family === 6 && privateIPv6(hostname))) invalid()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function base64url(value, maximum = MAX_TOKEN_BYTES) {
|
|
82
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > maximum || !/^[A-Za-z0-9_-]+$/.test(value)) invalid()
|
|
83
|
+
const bytes = Buffer.from(value, 'base64url')
|
|
84
|
+
if (bytes.toString('base64url') !== value) invalid()
|
|
85
|
+
return bytes
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function key(value, type) {
|
|
89
|
+
const der = base64url(value, 4096)
|
|
90
|
+
const options = { key: der, format: 'der', type }
|
|
91
|
+
const parsed = type === 'pkcs8' ? createPrivateKey(options) : createPublicKey(options)
|
|
92
|
+
if (parsed.asymmetricKeyType !== 'ed25519' || !parsed.export({ format: 'der', type }).equals(der)) invalid()
|
|
93
|
+
return parsed
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateCertificate(value, now) {
|
|
97
|
+
record(value, CERTIFICATE_FIELDS)
|
|
98
|
+
if (value.version !== 1 || typeof value.environmentId !== 'string' || !UUID.test(value.environmentId)) invalid()
|
|
99
|
+
origin(value.platformOrigin)
|
|
100
|
+
origin(value.sourceOrigin)
|
|
101
|
+
if (value.platformOrigin === value.sourceOrigin) invalid()
|
|
102
|
+
key(value.publicKey, 'spki')
|
|
103
|
+
lifetime(value, 604800, now)
|
|
104
|
+
return value
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateClaims(value, certificate, now) {
|
|
108
|
+
if (!value || typeof value !== 'object') invalid()
|
|
109
|
+
const fields = value.purpose === 'site' ? [...SESSION_FIELDS, 'siteOrigin', 'branchMediaKey'] : SESSION_FIELDS
|
|
110
|
+
record(value, fields)
|
|
111
|
+
if (value.version !== 1 || (value.purpose !== 'site' && value.purpose !== 'reference')) invalid()
|
|
112
|
+
shortString(value.tenantPublicId)
|
|
113
|
+
if (typeof value.nonce !== 'string' || !UUID.test(value.nonce)) invalid()
|
|
114
|
+
if (value.purpose === 'site') {
|
|
115
|
+
origin(value.siteOrigin)
|
|
116
|
+
shortString(value.branchMediaKey)
|
|
117
|
+
}
|
|
118
|
+
lifetime(value, 900, now)
|
|
119
|
+
if (value.issuedAt < certificate.issuedAt || value.expiresAt > certificate.expiresAt) invalid()
|
|
120
|
+
return value
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Both signer and verifier consume exactly these bytes, including the protocol
|
|
124
|
+
// prefix. Sorting recursively gives a single encoding for the signed envelope.
|
|
125
|
+
function canonical(value) {
|
|
126
|
+
if (value && typeof value === 'object') {
|
|
127
|
+
return `{${Object.keys(value).sort().map(field => `${JSON.stringify(field)}:${canonical(value[field])}`).join(',')}}`
|
|
128
|
+
}
|
|
129
|
+
return JSON.stringify(value)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function encode(prefix, payload, privateKey) {
|
|
133
|
+
const encoded = Buffer.from(canonical(payload)).toString('base64url')
|
|
134
|
+
const signedBytes = Buffer.from(`${prefix}.${encoded}`)
|
|
135
|
+
const signature = sign(null, signedBytes, privateKey).toString('base64url')
|
|
136
|
+
const token = `${prefix}.${encoded}.${signature}`
|
|
137
|
+
if (Buffer.byteLength(token) > MAX_TOKEN_BYTES) invalid()
|
|
138
|
+
return token
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function decode(token, prefix) {
|
|
142
|
+
if (typeof token !== 'string' || Buffer.byteLength(token) > MAX_TOKEN_BYTES) invalid()
|
|
143
|
+
const parts = token.split('.')
|
|
144
|
+
if (parts.length !== 3 || parts[0] !== prefix) invalid()
|
|
145
|
+
const bytes = base64url(parts[1])
|
|
146
|
+
// Preserve a leading BOM so decoding cannot turn noncanonical bytes into JSON.
|
|
147
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
|
|
148
|
+
const payload = JSON.parse(text)
|
|
149
|
+
if (canonical(payload) !== text) invalid()
|
|
150
|
+
const signature = base64url(parts[2])
|
|
151
|
+
if (signature.length !== 64) invalid()
|
|
152
|
+
return { payload, signature, signedBytes: Buffer.from(`${prefix}.${parts[1]}`) }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function verifySignature(envelope, publicKey) {
|
|
156
|
+
if (!verify(null, envelope.signedBytes, publicKey, envelope.signature)) invalid()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function generatePreviewKeyPair() {
|
|
160
|
+
return bounded(() => {
|
|
161
|
+
const pair = generateKeyPairSync('ed25519', {
|
|
162
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
|
|
163
|
+
publicKeyEncoding: { type: 'spki', format: 'der' },
|
|
164
|
+
})
|
|
165
|
+
return { privateKey: pair.privateKey.toString('base64url'), publicKey: pair.publicKey.toString('base64url') }
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function signEnvironmentCertificate(claims, privateKey) {
|
|
170
|
+
return bounded(() => encode(CERTIFICATE_PREFIX, validateCertificate(claims), key(privateKey, 'pkcs8')))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function verifyEnvironmentCertificate(certificate, publicKey, options = {}) {
|
|
174
|
+
return bounded(() => {
|
|
175
|
+
const now = options.now === undefined ? Math.floor(Date.now() / 1000) : options.now
|
|
176
|
+
const envelope = decode(certificate, CERTIFICATE_PREFIX)
|
|
177
|
+
verifySignature(envelope, key(publicKey, 'spki'))
|
|
178
|
+
return validateCertificate(envelope.payload, now)
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function signPreviewSession(claims, certificate, privateKey) {
|
|
183
|
+
return bounded(() => {
|
|
184
|
+
// The branch can inspect its certificate but does not possess the issuer's
|
|
185
|
+
// public key here. Issuer verification is mandatory at the receiving side.
|
|
186
|
+
const environment = validateCertificate(decode(certificate, CERTIFICATE_PREFIX).payload)
|
|
187
|
+
const parsedKey = key(privateKey, 'pkcs8')
|
|
188
|
+
if (createPublicKey(parsedKey).export({ type: 'spki', format: 'der' }).toString('base64url') !== environment.publicKey) invalid()
|
|
189
|
+
validateClaims(claims, environment)
|
|
190
|
+
return encode(SESSION_PREFIX, { certificate, claims }, parsedKey)
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function verifyPreviewSession(token, options) {
|
|
195
|
+
return bounded(() => {
|
|
196
|
+
if (!options || (options.purpose !== 'reference' && options.purpose !== 'site')) invalid()
|
|
197
|
+
const now = options.now === undefined ? Math.floor(Date.now() / 1000) : options.now
|
|
198
|
+
const envelope = decode(token, SESSION_PREFIX)
|
|
199
|
+
record(envelope.payload, ['certificate', 'claims'])
|
|
200
|
+
const certificate = verifyEnvironmentCertificate(envelope.payload.certificate, options.issuerPublicKey, { now })
|
|
201
|
+
verifySignature(envelope, key(certificate.publicKey, 'spki'))
|
|
202
|
+
const claims = validateClaims(envelope.payload.claims, certificate, now)
|
|
203
|
+
if (claims.purpose !== options.purpose) invalid()
|
|
204
|
+
for (const [field, expected] of [
|
|
205
|
+
['sourceOrigin', certificate.sourceOrigin], ['platformOrigin', certificate.platformOrigin],
|
|
206
|
+
['siteOrigin', claims.purpose === 'site' ? claims.siteOrigin : undefined], ['tenantPublicId', claims.tenantPublicId],
|
|
207
|
+
]) {
|
|
208
|
+
if (Object.hasOwn(options, field)) {
|
|
209
|
+
if (field === 'tenantPublicId') shortString(options[field])
|
|
210
|
+
else origin(options[field])
|
|
211
|
+
if (options[field] !== expected) invalid()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { certificate, claims }
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function isTestPreviewToken(value) {
|
|
219
|
+
// Detect malformed prefixed credentials too, so callers can refuse them
|
|
220
|
+
// instead of passing them through legacy draft/production authentication.
|
|
221
|
+
return typeof value === 'string' && value.startsWith(SESSION_PREFIX)
|
|
222
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { readBoundedJSON } from '../features/server/http'
|
|
4
|
+
import type { Env } from '../server/read'
|
|
5
|
+
import {
|
|
6
|
+
isTestPreviewToken,
|
|
7
|
+
verifyPreviewSession,
|
|
8
|
+
type EnvironmentCertificate,
|
|
9
|
+
type SitePreviewClaims,
|
|
10
|
+
} from './protocol.mjs'
|
|
11
|
+
|
|
12
|
+
const TrustSchema = z.strictObject({
|
|
13
|
+
version: z.literal(1),
|
|
14
|
+
issuerPublicKey: z.string().min(1).max(1024),
|
|
15
|
+
tenantPublicId: z.string().min(1).max(256),
|
|
16
|
+
siteOrigin: z.string().url(),
|
|
17
|
+
mediaKey: z
|
|
18
|
+
.string()
|
|
19
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/)
|
|
20
|
+
.max(256)
|
|
21
|
+
.refine((value) => !value.includes('..')),
|
|
22
|
+
})
|
|
23
|
+
export type TestPreviewTrust = z.infer<typeof TrustSchema>
|
|
24
|
+
export type TestPreviewContext = {
|
|
25
|
+
token: string
|
|
26
|
+
platformOrigin: string
|
|
27
|
+
siteOrigin: string
|
|
28
|
+
environmentId: string
|
|
29
|
+
visitorCookieName: string
|
|
30
|
+
certificate: EnvironmentCertificate
|
|
31
|
+
claims: SitePreviewClaims
|
|
32
|
+
trust: TestPreviewTrust
|
|
33
|
+
}
|
|
34
|
+
export type TestPreviewOptions = {
|
|
35
|
+
env: Env
|
|
36
|
+
fetch?: typeof fetch
|
|
37
|
+
now?: () => number
|
|
38
|
+
buildId?: string
|
|
39
|
+
trustTimeoutMs?: number
|
|
40
|
+
}
|
|
41
|
+
export class TestPreviewRefused extends Error {
|
|
42
|
+
constructor() {
|
|
43
|
+
super('Test preview is unavailable or expired')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export const previewResponseHeaders = {
|
|
47
|
+
'cache-control': 'no-store',
|
|
48
|
+
'referrer-policy': 'no-referrer',
|
|
49
|
+
'x-robots-tag': 'noindex, nofollow',
|
|
50
|
+
}
|
|
51
|
+
export const EXPECTED_PREVIEW_PARAM = 'u90test'
|
|
52
|
+
/** Cookie parsing refuses duplicates rather than choosing an attacker-controlled value. */
|
|
53
|
+
export function requestCookie(request: Request, name: string): string | null {
|
|
54
|
+
const prefix = name + '='
|
|
55
|
+
const values = (request.headers.get('cookie') ?? '')
|
|
56
|
+
.split(';')
|
|
57
|
+
.map((value) => value.trim())
|
|
58
|
+
.filter((value) => value.startsWith(prefix))
|
|
59
|
+
if (values.length > 1) throw new TestPreviewRefused()
|
|
60
|
+
return values[0]?.slice(prefix.length) ?? null
|
|
61
|
+
}
|
|
62
|
+
export function requestPreviewToken(request: Request): string | null {
|
|
63
|
+
return requestCookie(request, 'u90-preview-token')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createTestPreviewRuntime(options: TestPreviewOptions) {
|
|
67
|
+
const now = options.now ?? (() => Math.floor(Date.now() / 1000))
|
|
68
|
+
let cached: { trust: TestPreviewTrust; expiresAt: number } | undefined
|
|
69
|
+
let pending: Promise<TestPreviewTrust> | undefined
|
|
70
|
+
const getTrust = async (): Promise<TestPreviewTrust> => {
|
|
71
|
+
if (cached && now() < cached.expiresAt) return cached.trust
|
|
72
|
+
if (pending) return pending
|
|
73
|
+
pending = (async () => {
|
|
74
|
+
const { env } = options
|
|
75
|
+
if (!env.PLATFORM_URL || !env.PAYLOAD_API_KEY || !env.SITE_URL) throw new TestPreviewRefused()
|
|
76
|
+
const source = new URL(env.PLATFORM_URL),
|
|
77
|
+
site = new URL(env.SITE_URL)
|
|
78
|
+
if (
|
|
79
|
+
source.protocol !== 'https:' ||
|
|
80
|
+
source.username ||
|
|
81
|
+
source.password ||
|
|
82
|
+
source.search ||
|
|
83
|
+
source.hash ||
|
|
84
|
+
site.protocol !== 'https:'
|
|
85
|
+
)
|
|
86
|
+
throw new TestPreviewRefused()
|
|
87
|
+
const controller = new AbortController()
|
|
88
|
+
let response: Response | undefined
|
|
89
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
90
|
+
try {
|
|
91
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
92
|
+
timer = setTimeout(() => {
|
|
93
|
+
controller.abort()
|
|
94
|
+
reject(new TestPreviewRefused())
|
|
95
|
+
}, options.trustTimeoutMs ?? 5000)
|
|
96
|
+
})
|
|
97
|
+
const trust = await Promise.race([
|
|
98
|
+
timeout,
|
|
99
|
+
(async () => {
|
|
100
|
+
response = await (options.fetch ?? fetch)(
|
|
101
|
+
`${env.PLATFORM_URL!.replace(/\/+$/, '')}/api/u90/test-preview/trust`,
|
|
102
|
+
{
|
|
103
|
+
headers: { authorization: `Bearer ${env.PAYLOAD_API_KEY}` },
|
|
104
|
+
cache: 'no-store',
|
|
105
|
+
redirect: 'error',
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
if (!response.ok || response.status >= 300) throw new TestPreviewRefused()
|
|
110
|
+
const parsed = TrustSchema.parse(
|
|
111
|
+
await readBoundedJSON(response, 16384, controller.signal),
|
|
112
|
+
)
|
|
113
|
+
if (parsed.siteOrigin !== site.origin) throw new TestPreviewRefused()
|
|
114
|
+
return parsed
|
|
115
|
+
})(),
|
|
116
|
+
])
|
|
117
|
+
cached = { trust, expiresAt: now() + 300 }
|
|
118
|
+
return trust
|
|
119
|
+
} finally {
|
|
120
|
+
clearTimeout(timer)
|
|
121
|
+
}
|
|
122
|
+
})().finally(() => {
|
|
123
|
+
pending = undefined
|
|
124
|
+
})
|
|
125
|
+
return pending
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
async resolve(token: string | null): Promise<TestPreviewContext | null> {
|
|
129
|
+
if (!isTestPreviewToken(token)) return null
|
|
130
|
+
try {
|
|
131
|
+
const trust = await getTrust()
|
|
132
|
+
const verified = verifyPreviewSession(token!, {
|
|
133
|
+
issuerPublicKey: trust.issuerPublicKey,
|
|
134
|
+
purpose: 'site',
|
|
135
|
+
sourceOrigin: new URL(options.env.PLATFORM_URL!).origin,
|
|
136
|
+
siteOrigin: trust.siteOrigin,
|
|
137
|
+
tenantPublicId: trust.tenantPublicId,
|
|
138
|
+
now: now(),
|
|
139
|
+
})
|
|
140
|
+
const claims = verified.claims as SitePreviewClaims
|
|
141
|
+
return {
|
|
142
|
+
...verified,
|
|
143
|
+
claims,
|
|
144
|
+
trust,
|
|
145
|
+
token: token!,
|
|
146
|
+
platformOrigin: verified.certificate.platformOrigin,
|
|
147
|
+
siteOrigin: claims.siteOrigin,
|
|
148
|
+
environmentId: verified.certificate.environmentId,
|
|
149
|
+
visitorCookieName:
|
|
150
|
+
'u90_feature_test_' +
|
|
151
|
+
createHash('sha256')
|
|
152
|
+
.update(
|
|
153
|
+
verified.certificate.environmentId +
|
|
154
|
+
'\n' +
|
|
155
|
+
verified.certificate.platformOrigin +
|
|
156
|
+
'\n' +
|
|
157
|
+
claims.tenantPublicId,
|
|
158
|
+
)
|
|
159
|
+
.digest('hex')
|
|
160
|
+
.slice(0, 32),
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
throw new TestPreviewRefused()
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export type TestPreviewRuntime = ReturnType<typeof createTestPreviewRuntime>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { testPreviewProxy, TEST_PREVIEW_ACTIONS, type TestPreviewCapabilities } from './guard'
|
|
2
|
+
export {
|
|
3
|
+
createTestPreviewRuntime,
|
|
4
|
+
TestPreviewRefused,
|
|
5
|
+
EXPECTED_PREVIEW_PARAM,
|
|
6
|
+
type TestPreviewContext,
|
|
7
|
+
type TestPreviewTrust,
|
|
8
|
+
type TestPreviewOptions,
|
|
9
|
+
type TestPreviewRuntime,
|
|
10
|
+
} from './runtime'
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { applyOverlay, newDocumentId, seed, type DocumentDecl, type OverlayValues } from '../elements'
|
|
2
|
+
import { hasTranslatedCopy, type SiteLanguages } from '../languages/routing'
|
|
3
|
+
import { assemble, manifestFrom, manifestHash, replyFrom, type Reply, type ReplySeo, type ReplyTarget, type RouteRead, type Site, type SiteReply } from '../site'
|
|
4
|
+
import type { RedirectsReply } from '../redirects/wire'
|
|
5
|
+
import { seedCtx, seedEntry, seedSite, type Store } from '../site/store'
|
|
6
|
+
|
|
7
|
+
export type { RouteRead }
|
|
8
|
+
const assembled = (site: Site, store: Store, target: ReplyTarget, draft: boolean): RouteRead =>
|
|
9
|
+
assemble(site, replyFrom(site, store, target, ''), { draft, target, stale: false, defaults: null })
|
|
10
|
+
/** A route read against a store, assembled as the site assembles a platform's reply. */
|
|
11
|
+
export const routeRead = (site: Site, store: Store, pathname: string, draft: boolean): RouteRead => assembled(site, store, { route: pathname }, draft)
|
|
12
|
+
/**
|
|
13
|
+
* `/entry/:collection/:slug` of the read contract, as the site assembles it: independent of whether
|
|
14
|
+
* the collection has a public route, so a routeless collection (coastline's `sectors`) is readable too.
|
|
15
|
+
*/
|
|
16
|
+
export const entryRead = (site: Site, store: Store, collection: string, slug: string, draft: boolean): RouteRead =>
|
|
17
|
+
assembled(site, store, { entry: { collection, slug } }, draft)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The read contract, in-process: `/api/u90/v1/*` answered with replies built from a store, as the
|
|
21
|
+
* platform answers. `manifest` overrides the hash it reports, to play a platform behind a deploy.
|
|
22
|
+
* `failSite` makes `/site` answer 500, to play a platform whose site-wide SEO cannot be read.
|
|
23
|
+
* `redirects` are the rules `/redirects` answers with; `setRedirects` replaces them.
|
|
24
|
+
* `languages` plays a platform whose site has them (an object) or none (null); left out, the fake accepts any `locale` and answers the base, as before languages.
|
|
25
|
+
*/
|
|
26
|
+
export async function createFakePlatform(site: Site, store?: Store, opts: { manifest?: string; failSite?: boolean; redirects?: RedirectsReply['rules']; languages?: SiteLanguages | null } = {}) {
|
|
27
|
+
const s = store ?? (await seedSite(site))
|
|
28
|
+
const manifest = opts.manifest ?? (await manifestHash(manifestFrom(site)))
|
|
29
|
+
const revalidated: string[] = []
|
|
30
|
+
let redirects: RedirectsReply['rules'] = opts.redirects ?? []
|
|
31
|
+
const ctx = seedCtx(s.media)
|
|
32
|
+
/** Undefined until languages are given: the fake then answers any `locale` with the base, as it did before languages. */
|
|
33
|
+
let languages: SiteLanguages | null | undefined = opts.languages
|
|
34
|
+
/** By document key, then by locale. */
|
|
35
|
+
const translations = new Map<string, Map<string, OverlayValues>>()
|
|
36
|
+
/** The overlay layers of a read, most preferred first, as plan 1's `languageChain` lists them; null for a locale this site cannot answer. */
|
|
37
|
+
const chainOf = (requested: string | null): { locale: string | null; layers: string[] } | null => {
|
|
38
|
+
if (languages === undefined) return { locale: null, layers: [] }
|
|
39
|
+
if (languages === null) return requested === null ? { locale: null, layers: [] } : null
|
|
40
|
+
const locale = requested ?? languages.default
|
|
41
|
+
if (!languages.active.some((language) => language.id === locale)) return null
|
|
42
|
+
return { locale, layers: [locale, languages.default].filter((id, at, all) => id !== languages!.original && all.indexOf(id) === at) }
|
|
43
|
+
}
|
|
44
|
+
/** As the platform answers it (ruling R2): the active languages, other than the original and the default, whose own translation of `document` holds copy. */
|
|
45
|
+
const translatedOf = (document: string): string[] =>
|
|
46
|
+
languages ? languages.active.map((l) => l.id).filter((id) => id !== languages!.default && id !== languages!.original && hasTranslatedCopy(translations.get(document)?.get(id))) : []
|
|
47
|
+
/** A sitemap entry with its languages, as the platform lists them, for a site with languages. */
|
|
48
|
+
const withLanguages = (entry: { path: string; lastModified: string | null }, document: string) =>
|
|
49
|
+
languages ? { ...entry, languages: languages.active.map((l) => l.id).filter((id) => id === languages!.default || id === languages!.original || translatedOf(document).includes(id)) } : entry
|
|
50
|
+
const layoutDecl: DocumentDecl = { kind: 'layout', blocks: { container: 'u90/zone', allow: Object.keys(site.schema.blocks) } }
|
|
51
|
+
/** The store with the layers laid over every page, layout and Single; the store itself is never changed. */
|
|
52
|
+
const overlaid = (layers: string[]): Store => {
|
|
53
|
+
if (layers.length === 0) return s
|
|
54
|
+
const of = (doc: string) => layers.flatMap((locale) => { const values = translations.get(doc)?.get(locale); return values ? [values] : [] })
|
|
55
|
+
const pages = new Map([...s.pages].map(([k, p]) => {
|
|
56
|
+
const blocks = p.key ? site.pages[p.key]?.blocks : p.pageType ? site.pageTypes[p.pageType]?.blocks : undefined
|
|
57
|
+
return [k, blocks ? { ...p, doc: applyOverlay(site.schema, { kind: 'page', blocks }, p.doc, `page:${p.id}`, of(`page:${p.id}`)).doc } : p]
|
|
58
|
+
}))
|
|
59
|
+
const layouts = new Map([...s.layouts].map(([k, l]) => [k, { ...l, doc: applyOverlay(site.schema, layoutDecl, l.doc, `layout:${k}`, of(`layout:${k}`)).doc }]))
|
|
60
|
+
const singles = new Map([...s.singles].map(([k, v]) => [k, { doc: applyOverlay(site.schema, { kind: 'single', key: k }, v.doc, `single:${k}`, of(`single:${k}`)).doc as { value: unknown } }]))
|
|
61
|
+
return { ...s, pages, layouts, singles }
|
|
62
|
+
}
|
|
63
|
+
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
|
|
64
|
+
/**
|
|
65
|
+
* `/site` by default: an indexed site, with the manifest's title template and nothing else set.
|
|
66
|
+
* The real platform answers `allowIndexing: false` for a tenant that has not saved SEO settings;
|
|
67
|
+
* `setSite` plays that tenant.
|
|
68
|
+
*/
|
|
69
|
+
const settings: SiteReply = { allowIndexing: true, siteName: null, titleTemplate: site.seo.titleTemplate ?? null, description: null, ogImage: null, locale: null, twitterCard: 'summary_large_image', twitterHandle: null, googleVerification: null, bingVerification: null, organisation: null }
|
|
70
|
+
/** The read contract carries `Authorization: Bearer` on every read (spec §4): checked before dispatch, regardless of whether the path resolves. */
|
|
71
|
+
const fetchImpl: typeof fetch = async (input, init) => {
|
|
72
|
+
const url = new URL(input instanceof URL ? input.href : typeof input === 'string' ? input : input.url)
|
|
73
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
|
|
74
|
+
if (!headers.get('authorization')?.startsWith('Bearer ')) return json({ error: 'unauthorised' }, 401)
|
|
75
|
+
// With no languages given, `locale` is accepted and the base answered. With languages, a `locale` must name an active one, and the layers [requested, default] are laid over pages, layouts and Singles; entries are not overlaid.
|
|
76
|
+
const draft = url.searchParams.get('draft') === '1'
|
|
77
|
+
if (draft && !headers.get('x-u90-preview-token')) return json({ error: 'preview token' }, 401)
|
|
78
|
+
const p = url.pathname.replace(/^.*\/api\/u90\/v1/, '')
|
|
79
|
+
const chain = chainOf(url.searchParams.get('locale') || null)
|
|
80
|
+
const reads = p === '/route' || p.startsWith('/page/') || p.startsWith('/entry/')
|
|
81
|
+
if (reads && chain === null) return json({ error: 'Unknown language.' }, 400)
|
|
82
|
+
const view = chain ? overlaid(chain.layers) : s
|
|
83
|
+
// A site with languages hears which language answered, as the platform says it, and which languages have copy of the document (ruling R2).
|
|
84
|
+
const answer = (reply: Reply) =>
|
|
85
|
+
json(reply.status === 'ok' && chain?.locale ? { ...reply, locale: chain.locale, ...(languages ? { translated: translatedOf(`${reply.document.kind}:${reply.document.id}`) } : {}) } : reply)
|
|
86
|
+
if (p === '/route') return answer(replyFrom(site, view, { route: url.searchParams.get('path') ?? '/' }, manifest))
|
|
87
|
+
if (p.startsWith('/page/')) return answer(replyFrom(site, view, { page: decodeURIComponent(p.slice(6)) }, manifest))
|
|
88
|
+
if (p.startsWith('/entry/')) {
|
|
89
|
+
// The read client encodes both segments (and a URL's pathname keeps them encoded), so a
|
|
90
|
+
// non-ASCII slug arrives as `caf%C3%A9`; decoded after the split, so an encoded `/` stays in its segment.
|
|
91
|
+
let segments: string[]
|
|
92
|
+
try { segments = p.split('/').slice(2).map(decodeURIComponent) } catch { return json({ error: 'malformed path' }, 400) }
|
|
93
|
+
return answer(replyFrom(site, view, { entry: { collection: segments[0], slug: segments[1] } }, manifest))
|
|
94
|
+
}
|
|
95
|
+
if (p === '/site') return opts.failSite ? json({ error: 'site settings unavailable' }, 500) : json(languages ? { ...settings, languages } : settings)
|
|
96
|
+
if (p === '/sitemap') {
|
|
97
|
+
// As the platform does: nothing while indexing is off, and no document whose own SEO says noindex.
|
|
98
|
+
if (!settings.allowIndexing) return json({ entries: [] })
|
|
99
|
+
// A site with languages lists each entry in the languages it has copy in (ruling R2).
|
|
100
|
+
const entries = [...s.entries.values()].flatMap((e) => (site.collections[e.collection]?.route && !e.seo?.noindex ? [withLanguages({ path: site.collections[e.collection].route!.replace(':slug', e.slug), lastModified: null }, `entry:${e.id}`)] : []))
|
|
101
|
+
return json({ entries: [...[...s.pages.values()].filter((x) => !x.seo?.noindex).map((x) => withLanguages({ path: x.route, lastModified: null }, `page:${x.id}`)), ...entries] })
|
|
102
|
+
}
|
|
103
|
+
if (p === '/redirects') return json({ rules: redirects, generatedAt: new Date().toISOString() })
|
|
104
|
+
if (p === '/revalidate') { const body = (await (init?.body ? new Response(init.body) : (input as Request)).json()) as { tags: string[] }; revalidated.push(...body.tags); return json({ ok: true }) }
|
|
105
|
+
return json({ error: 'not found' }, 404)
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
fetch: fetchImpl, store: s, revalidated, manifest,
|
|
109
|
+
async addEntry(collection: string, slug: string, fields: Record<string, unknown>, blocks?: unknown[]) {
|
|
110
|
+
await seedEntry(site, s, ctx, collection, slug, fields, blocks)
|
|
111
|
+
},
|
|
112
|
+
async addPage(pageType: string, slug: string, blocks?: unknown[], title?: string) {
|
|
113
|
+
const t = site.pageTypes[pageType]
|
|
114
|
+
if (!t) throw new Error(`No page type named "${pageType}".`)
|
|
115
|
+
const id = newDocumentId()
|
|
116
|
+
const doc = await seed(site.schema, { kind: 'page', blocks: t.blocks }, blocks, ctx, `page:${id}`)
|
|
117
|
+
s.pages.set(id, { id, slug, route: t.route.replace(':slug', slug), layout: t.layout, pageType, title: title ?? slug, doc })
|
|
118
|
+
return id
|
|
119
|
+
},
|
|
120
|
+
setStored(kind: 'page' | 'layout' | 'single', id: string, pointer: string, value: unknown) {
|
|
121
|
+
if (!/^\/[^/]+(\/[^/]+)*$/.test(pointer)) throw new Error(`"${pointer}" is not a valid pointer: expected a leading "/" and at least one segment, e.g. "/blocks/0/headline".`)
|
|
122
|
+
const entry = kind === 'page' ? s.pages.get(id) : kind === 'layout' ? s.layouts.get(id) : s.singles.get(id)
|
|
123
|
+
if (!entry) throw new Error(`No ${kind} named "${id}" is stored.`)
|
|
124
|
+
const segs = pointer.split('/').slice(1); let node: any = entry.doc
|
|
125
|
+
for (const seg of segs.slice(0, -1)) {
|
|
126
|
+
if (node === null || typeof node !== 'object' || !(seg in node)) throw new Error(`"${pointer}" has no node at "${seg}" (in ${kind} "${id}").`)
|
|
127
|
+
node = node[seg]
|
|
128
|
+
}
|
|
129
|
+
node[segs.at(-1)!] = value
|
|
130
|
+
},
|
|
131
|
+
setSeo(target: { page: string } | { entry: { collection: string; slug: string } }, seo: ReplySeo) {
|
|
132
|
+
const doc = 'page' in target ? s.pages.get(target.page) : [...s.entries.values()].find((e) => e.collection === target.entry.collection && e.slug === target.entry.slug)
|
|
133
|
+
if (!doc) throw new Error(`Nothing stored for ${JSON.stringify(target)}.`)
|
|
134
|
+
doc.seo = seo
|
|
135
|
+
},
|
|
136
|
+
setSite(changes: Partial<SiteReply>) { Object.assign(settings, changes) },
|
|
137
|
+
/** Replaces the rules `/redirects` answers with. */
|
|
138
|
+
setRedirects(next: RedirectsReply['rules']) { redirects = next },
|
|
139
|
+
/** Plays a platform whose site has these languages; null plays one whose site has none. */
|
|
140
|
+
setLanguages(next: SiteLanguages | null) { languages = next },
|
|
141
|
+
/** Stores one language's overlay of a page (`page:<id>`), layout (`layout:<key>`) or Single (`single:<key>`). */
|
|
142
|
+
setTranslation(document: string, locale: string, values: OverlayValues) {
|
|
143
|
+
const byLocale = translations.get(document) ?? new Map<string, OverlayValues>()
|
|
144
|
+
byLocale.set(locale, values)
|
|
145
|
+
translations.set(document, byLocale)
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|