@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,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL continuity for a site that was rebuilt — the normaliser, the matcher, and the
|
|
3
|
+
* middleware that runs them on every request.
|
|
4
|
+
*
|
|
5
|
+
* **Its own entry point, and that is load-bearing.** This module is imported from a
|
|
6
|
+
* site's `src/middleware.ts`, which runs in the Edge runtime. The package's root barrel
|
|
7
|
+
* re-exports `content.ts` (which holds `PAYLOAD_API_KEY`), `richText.tsx` and
|
|
8
|
+
* `lexicalDom.ts` — so importing `@unitninety/site-kit` from middleware would drag React
|
|
9
|
+
* and the key-holding fetcher into the edge bundle. `/bridge`, `/draft` and `/shape` are
|
|
10
|
+
* split for the same reason. Import from `@unitninety/site-kit/redirects`.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here imports Next, so the same code is used by the platform's own validator and
|
|
13
|
+
* CSV importer. That is deliberate: a normaliser with two implementations is a matcher
|
|
14
|
+
* that disagrees with the form that wrote the rule, and the disagreement only shows up on
|
|
15
|
+
* the URLs that needed a redirect most.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** What a rule can do when it matches. `410` means gone, and carries no destination. */
|
|
19
|
+
export type RedirectStatus = 301 | 302 | 410
|
|
20
|
+
|
|
21
|
+
export type RedirectMatchType = 'exact' | 'prefix' | 'pattern'
|
|
22
|
+
|
|
23
|
+
/** One rule, as the platform serves it and the matcher consumes it. */
|
|
24
|
+
export type RedirectRule = {
|
|
25
|
+
/** Already normalised by `normalisePath` when the platform stored it. */
|
|
26
|
+
source: string
|
|
27
|
+
/** Same-origin path, or an absolute URL for an off-site move. Empty for `410`. */
|
|
28
|
+
destination: string
|
|
29
|
+
status: RedirectStatus
|
|
30
|
+
matchType: RedirectMatchType
|
|
31
|
+
preserveQuery: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the platform's `GET /api/u90/redirects` returns, and what bakes into a snapshot. */
|
|
35
|
+
export type RedirectSnapshot = {
|
|
36
|
+
rules: RedirectRule[]
|
|
37
|
+
/** When the platform generated it. Carried for diagnostics, never used for matching. */
|
|
38
|
+
generatedAt?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Normalise a path so both sides of a comparison agree.
|
|
43
|
+
*
|
|
44
|
+
* The order is fixed and the steps are not independent — decoding before NFC matters,
|
|
45
|
+
* because a percent-encoded `%CC%88` is a combining diaeresis that only becomes
|
|
46
|
+
* comparable once it is a character.
|
|
47
|
+
*
|
|
48
|
+
* 1. Percent-decode.
|
|
49
|
+
* 2. Unicode-normalise to NFC.
|
|
50
|
+
* 3. Strip the trailing slash, except at root.
|
|
51
|
+
* 4. Lowercase.
|
|
52
|
+
*
|
|
53
|
+
* **Why NFC is not pedantry.** `ä` has two valid encodings: precomposed `U+00E4`, or `a`
|
|
54
|
+
* followed by a combining diaeresis `U+0308`. They render identically and compare
|
|
55
|
+
* unequal. A CMS export, a browser address bar and a pasted link do not reliably agree on
|
|
56
|
+
* which they emit — so without NFC on both sides a rule for `/post/bygglov-i-järfälla`
|
|
57
|
+
* silently fails to match a request for what is visibly the same URL. It fails for exactly
|
|
58
|
+
* the migrations that need it most, because an ASCII-only site never exposes the bug.
|
|
59
|
+
*
|
|
60
|
+
* Case folding is unconditional in phase one. §11 Q2 leaves a per-tenant override open;
|
|
61
|
+
* nothing has needed it yet, and a flag nobody sets is a branch nobody tests.
|
|
62
|
+
*/
|
|
63
|
+
export const normalisePath = (input: string): string => {
|
|
64
|
+
let path = input
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
* A malformed escape — a bare `%` or `%zz` — makes `decodeURIComponent` throw. That is a
|
|
68
|
+
* real thing to receive: old CMS exports contain them, and so do crawlers. Falling back
|
|
69
|
+
* to the raw string keeps the rule usable rather than failing the whole snapshot over one
|
|
70
|
+
* bad row.
|
|
71
|
+
*/
|
|
72
|
+
try {
|
|
73
|
+
path = decodeURIComponent(path)
|
|
74
|
+
} catch {
|
|
75
|
+
// Left as-is, deliberately.
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
path = path.normalize('NFC')
|
|
79
|
+
|
|
80
|
+
if (path.length > 1 && path.endsWith('/')) path = path.replace(/\/+$/, '')
|
|
81
|
+
// An input of `/` or `//` collapses to nothing above; root has to stay root.
|
|
82
|
+
if (path === '') path = '/'
|
|
83
|
+
|
|
84
|
+
return path.toLowerCase()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Strip the trailing slash exactly as `normalisePath` does, and nothing else.
|
|
89
|
+
*
|
|
90
|
+
* The raw request path is what a destination is built from — see `matchRedirect` — so it has
|
|
91
|
+
* to be brought into the same *shape* as the normalised path without being decoded or
|
|
92
|
+
* lowercased, or the two cannot be walked segment by segment together.
|
|
93
|
+
*/
|
|
94
|
+
const trimTrailingSlash = (path: string): string => {
|
|
95
|
+
if (path.length > 1 && path.endsWith('/')) {
|
|
96
|
+
const trimmed = path.replace(/\/+$/, '')
|
|
97
|
+
return trimmed === '' ? '/' : trimmed
|
|
98
|
+
}
|
|
99
|
+
return path
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Make a destination safe to put in a `Location` header.
|
|
104
|
+
*
|
|
105
|
+
* **A `Headers` value is a ByteString.** Anything above U+00FF throws outright, and so does a
|
|
106
|
+
* bare CR or LF — so a middleware that puts an un-encoded string here does not serve a bad
|
|
107
|
+
* redirect, it throws, and the whole request 500s. That turns any URL a visitor can craft into
|
|
108
|
+
* a way to take the middleware down, and it 500s genuine CJK and accented URLs as well: exactly
|
|
109
|
+
* the migrations the normaliser's NFC handling exists for.
|
|
110
|
+
*
|
|
111
|
+
* Only the bytes that cannot appear are touched. Everything printable-ASCII is left alone,
|
|
112
|
+
* `%` included, so a remainder that arrived already percent-encoded is not encoded twice.
|
|
113
|
+
* A lone surrogate — which `encodeURIComponent` refuses — is dropped rather than thrown over;
|
|
114
|
+
* it cannot have been part of a real address.
|
|
115
|
+
*/
|
|
116
|
+
export const encodeLocation = (destination: string): string =>
|
|
117
|
+
destination.replace(/[^\u0021-\u007E]+/gu, (run) => {
|
|
118
|
+
try {
|
|
119
|
+
return encodeURIComponent(run)
|
|
120
|
+
} catch {
|
|
121
|
+
return ''
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/** Split a request target into the path the matcher sees and the query it must carry. */
|
|
126
|
+
export const splitQuery = (target: string): { path: string; query: string } => {
|
|
127
|
+
const mark = target.indexOf('?')
|
|
128
|
+
if (mark === -1) return { path: target, query: '' }
|
|
129
|
+
return { path: target.slice(0, mark), query: target.slice(mark) }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The `:param` and `*` segments a pattern declares, in the order they appear. */
|
|
133
|
+
export const patternParams = (pattern: string): string[] => {
|
|
134
|
+
const found: string[] = []
|
|
135
|
+
for (const segment of pattern.split('/')) {
|
|
136
|
+
if (segment.startsWith(':') && segment.length > 1) found.push(segment.slice(1))
|
|
137
|
+
else if (segment === '*') found.push('*')
|
|
138
|
+
}
|
|
139
|
+
return found
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Match one pattern against a normalised path, returning the captures by name.
|
|
144
|
+
*
|
|
145
|
+
* Segment-wise rather than by compiled regex: a pattern arrives from a CSV a person typed,
|
|
146
|
+
* and building a regex out of untrusted text is how a redirect table becomes a ReDoS. The
|
|
147
|
+
* segment walk has no backtracking to exploit.
|
|
148
|
+
*
|
|
149
|
+
* `*` is a trailing wildcard — it captures the rest of the path, so `/blog/*` matches
|
|
150
|
+
* `/blog/2019/some-post`. A `*` anywhere but last matches a single segment.
|
|
151
|
+
*
|
|
152
|
+
* **Matching reads the normalised path; captures come from `outputSegments`.** The two are
|
|
153
|
+
* the same string walked in step, and separating them is the whole point: normalisation is a
|
|
154
|
+
* comparison transform — it decodes and lowercases — so a capture taken from it is a decoded,
|
|
155
|
+
* case-folded string that must never be emitted. See `matchRedirect`.
|
|
156
|
+
*/
|
|
157
|
+
const matchPattern = (
|
|
158
|
+
pattern: string,
|
|
159
|
+
path: string,
|
|
160
|
+
outputSegments?: string[],
|
|
161
|
+
): Record<string, string> | null => {
|
|
162
|
+
const patternSegments = pattern.split('/')
|
|
163
|
+
const pathSegments = path.split('/')
|
|
164
|
+
const emit = outputSegments ?? pathSegments
|
|
165
|
+
const captures: Record<string, string> = {}
|
|
166
|
+
|
|
167
|
+
for (let index = 0; index < patternSegments.length; index += 1) {
|
|
168
|
+
const expected = patternSegments[index] ?? ''
|
|
169
|
+
|
|
170
|
+
// Trailing wildcard: swallow whatever is left, including nothing.
|
|
171
|
+
if (expected === '*' && index === patternSegments.length - 1) {
|
|
172
|
+
captures['*'] = emit.slice(index).join('/')
|
|
173
|
+
return captures
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const actual = pathSegments[index]
|
|
177
|
+
if (actual === undefined) return null
|
|
178
|
+
|
|
179
|
+
if (expected.startsWith(':') && expected.length > 1) {
|
|
180
|
+
// A named param never matches an empty segment — `/post/` must not satisfy
|
|
181
|
+
// `/post/:slug` and redirect to a destination with a hole in it.
|
|
182
|
+
if (actual === '') return null
|
|
183
|
+
captures[expected.slice(1)] = emit[index] ?? actual
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (expected === '*') {
|
|
188
|
+
if (actual === '') return null
|
|
189
|
+
captures['*'] = emit[index] ?? actual
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (expected !== actual) return null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Anything left over means the path was longer than the pattern, and a pattern with no
|
|
197
|
+
// trailing wildcard does not match a deeper path.
|
|
198
|
+
return pathSegments.length === patternSegments.length ? captures : null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Substitute a pattern rule's captures into its destination. */
|
|
202
|
+
export const applyCaptures = (destination: string, captures: Record<string, string>): string =>
|
|
203
|
+
destination
|
|
204
|
+
.split('/')
|
|
205
|
+
.map((segment) => {
|
|
206
|
+
if (segment === '*') return captures['*'] ?? ''
|
|
207
|
+
if (segment.startsWith(':') && segment.length > 1) {
|
|
208
|
+
return captures[segment.slice(1)] ?? segment
|
|
209
|
+
}
|
|
210
|
+
return segment
|
|
211
|
+
})
|
|
212
|
+
.join('/')
|
|
213
|
+
|
|
214
|
+
/** What the matcher decided. `null` from `matchRedirect` means no rule applied. */
|
|
215
|
+
export type RedirectMatch = {
|
|
216
|
+
destination: string
|
|
217
|
+
status: RedirectStatus
|
|
218
|
+
/** The rule that won, for logging and for the dashboard's "which rule fired" answer. */
|
|
219
|
+
rule: RedirectRule
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A compiled rule set. Built once per snapshot, not per request.
|
|
224
|
+
*
|
|
225
|
+
* The exact map is what makes a 270-rule table free at the edge; the prefix list is sorted
|
|
226
|
+
* longest-first at compile time so `/blog/archive` beats `/blog` without the author having
|
|
227
|
+
* to think about ordering.
|
|
228
|
+
*/
|
|
229
|
+
export type CompiledRedirects = {
|
|
230
|
+
exact: Map<string, RedirectRule>
|
|
231
|
+
prefix: RedirectRule[]
|
|
232
|
+
pattern: RedirectRule[]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export const compileRedirects = (rules: RedirectRule[]): CompiledRedirects => {
|
|
236
|
+
const exact = new Map<string, RedirectRule>()
|
|
237
|
+
const prefix: RedirectRule[] = []
|
|
238
|
+
const pattern: RedirectRule[] = []
|
|
239
|
+
|
|
240
|
+
for (const rule of rules) {
|
|
241
|
+
// Normalised again here rather than trusted: a snapshot baked at build time may predate
|
|
242
|
+
// a change to this function, and a rule the matcher can never reach is worse than a
|
|
243
|
+
// cheap second pass over a few hundred strings.
|
|
244
|
+
const normalised: RedirectRule = { ...rule, source: normalisePath(rule.source) }
|
|
245
|
+
if (normalised.matchType === 'exact') exact.set(normalised.source, normalised)
|
|
246
|
+
else if (normalised.matchType === 'prefix') prefix.push(normalised)
|
|
247
|
+
else pattern.push(normalised)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Longest source first. Ties broken by string order so the result is deterministic
|
|
251
|
+
// across snapshots — a matcher that depends on database row order is one that changes
|
|
252
|
+
// behaviour when somebody edits an unrelated rule.
|
|
253
|
+
prefix.sort((a, b) => b.source.length - a.source.length || a.source.localeCompare(b.source))
|
|
254
|
+
pattern.sort(
|
|
255
|
+
(a, b) =>
|
|
256
|
+
patternParams(a.source).length - patternParams(b.source).length ||
|
|
257
|
+
b.source.length - a.source.length ||
|
|
258
|
+
a.source.localeCompare(b.source),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return { exact, prefix, pattern }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Resolve one request target against a compiled rule set. First match wins.
|
|
266
|
+
*
|
|
267
|
+
* Exact before prefix before pattern is what lets a single `/post/:slug` rule cover 226
|
|
268
|
+
* URLs while a handful of exact rules carve out the exceptions — the author writes the
|
|
269
|
+
* general rule and the specific ones, and never reasons about which is checked first.
|
|
270
|
+
*/
|
|
271
|
+
export const matchRedirect = (
|
|
272
|
+
compiled: CompiledRedirects,
|
|
273
|
+
target: string,
|
|
274
|
+
): RedirectMatch | null => {
|
|
275
|
+
const { path, query } = splitQuery(target)
|
|
276
|
+
const normalised = normalisePath(path)
|
|
277
|
+
|
|
278
|
+
/*
|
|
279
|
+
* **Matching uses the normalised path; the destination is built from the raw one.**
|
|
280
|
+
*
|
|
281
|
+
* `normalisePath` percent-decodes and lowercases, which is exactly right for deciding
|
|
282
|
+
* whether a rule applies and exactly wrong for producing an address. Taken as output it
|
|
283
|
+
* gives `/blog/%E6%97%A5%E6%9C%AC` back as literal CJK — which a `Location` header cannot
|
|
284
|
+
* hold at all — turns `%0D%0A` into a real CRLF, and folds `/News/Q3` to `/news/q3` on a
|
|
285
|
+
* case-sensitive route. The first two throw inside `new Response`, so any visitor could
|
|
286
|
+
* 500 the middleware on any prefix or pattern rule with a crafted URL.
|
|
287
|
+
*
|
|
288
|
+
* So the raw path is walked alongside the normalised one, trailing slash trimmed the same
|
|
289
|
+
* way so the two agree segment for segment. They only disagree when decoding introduces a
|
|
290
|
+
* separator — an encoded `%2F` — and then the raw side is abandoned for the normalised one
|
|
291
|
+
* rather than mis-aligned: `encodeLocation` below still makes that safe to emit.
|
|
292
|
+
*/
|
|
293
|
+
const rawSegments = trimTrailingSlash(path).split('/')
|
|
294
|
+
const normalisedSegments = normalised.split('/')
|
|
295
|
+
const outputSegments =
|
|
296
|
+
rawSegments.length === normalisedSegments.length ? rawSegments : normalisedSegments
|
|
297
|
+
|
|
298
|
+
const carry = (rule: RedirectRule, destination: string): RedirectMatch => {
|
|
299
|
+
if (rule.status === 410) return { destination: '', status: 410, rule }
|
|
300
|
+
/*
|
|
301
|
+
* The query is re-appended unless the rule opted out. A destination that carries its
|
|
302
|
+
* own query wins the `?` and the request's own params are dropped — merging two query
|
|
303
|
+
* strings produces duplicate keys whose precedence is framework-specific, which is a
|
|
304
|
+
* worse outcome than the rule author's explicit choice.
|
|
305
|
+
*/
|
|
306
|
+
const withQuery =
|
|
307
|
+
rule.preserveQuery && query && !destination.includes('?') ? destination + query : destination
|
|
308
|
+
/*
|
|
309
|
+
* Encoded last, once, over everything — the rule's own stored destination included. A
|
|
310
|
+
* destination is stored through `normalisePath` too, so `/nyheter/japan` spelled in
|
|
311
|
+
* Japanese reaches here decoded and would throw just as a capture would.
|
|
312
|
+
*/
|
|
313
|
+
return { destination: encodeLocation(withQuery), status: rule.status, rule }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const exact = compiled.exact.get(normalised)
|
|
317
|
+
if (exact) return carry(exact, exact.destination)
|
|
318
|
+
|
|
319
|
+
for (const rule of compiled.prefix) {
|
|
320
|
+
// A prefix must end at a segment boundary: `/blog` must not swallow `/blogroll`.
|
|
321
|
+
if (normalised === rule.source || normalised.startsWith(`${rule.source}/`)) {
|
|
322
|
+
// Counted in segments rather than sliced by character length, because the raw path and
|
|
323
|
+
// the normalised one share a segment count but not a length.
|
|
324
|
+
const consumed = rule.source.split('/').length
|
|
325
|
+
const rest =
|
|
326
|
+
outputSegments.length > consumed ? `/${outputSegments.slice(consumed).join('/')}` : ''
|
|
327
|
+
const destination =
|
|
328
|
+
rule.destination.endsWith('/') && rest.startsWith('/')
|
|
329
|
+
? rule.destination.slice(0, -1) + rest
|
|
330
|
+
: rule.destination + rest
|
|
331
|
+
return carry(rule, destination)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const rule of compiled.pattern) {
|
|
336
|
+
const captures = matchPattern(rule.source, normalised, outputSegments)
|
|
337
|
+
if (captures) return carry(rule, applyCaptures(rule.destination, captures))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return null
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The default matcher config for a site's `src/middleware.ts`.
|
|
345
|
+
*
|
|
346
|
+
* Exported as a constant so a site's `export const config` and this module's own guard
|
|
347
|
+
* cannot drift apart. Middleware that runs on `/_next/static/*` adds latency to every
|
|
348
|
+
* asset on every page and can never usefully redirect one.
|
|
349
|
+
*/
|
|
350
|
+
export const REDIRECT_MATCHER = '/((?!_next/|api/|.*\\.[a-zA-Z0-9]+$).*)'
|
|
351
|
+
|
|
352
|
+
/** Belt-and-braces for the matcher above — Next's config is declarative and easy to edit out. */
|
|
353
|
+
const isExcluded = (pathname: string): boolean =>
|
|
354
|
+
pathname.startsWith('/_next/') || pathname.startsWith('/api/') || /\.[a-zA-Z0-9]+$/.test(pathname)
|
|
355
|
+
|
|
356
|
+
type MinimalRequest = { url: string; nextUrl?: { pathname: string; search: string } }
|
|
357
|
+
type MinimalResponse = Response | undefined
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* `src/middleware.ts` — redirect a request before it reaches routing.
|
|
361
|
+
*
|
|
362
|
+
* import { createRedirectMiddleware } from '@unitninety/site-kit/redirects'
|
|
363
|
+
* import snapshot from './redirects.generated.json'
|
|
364
|
+
*
|
|
365
|
+
* export const middleware = createRedirectMiddleware({ snapshot })
|
|
366
|
+
* export const config = { matcher: [REDIRECT_MATCHER] }
|
|
367
|
+
*
|
|
368
|
+
* **Baked floor, refreshed in the background.** The snapshot compiles into the bundle, so
|
|
369
|
+
* the first request of a cold isolate is already correct and never blocks on the network.
|
|
370
|
+
* A module-scope cache then refreshes on a TTL, so a rule added in the dashboard is live
|
|
371
|
+
* within a minute with no deploy.
|
|
372
|
+
*
|
|
373
|
+
* **The TTL is the freshness mechanism, and the cache tag is not.** Next's Data Cache is
|
|
374
|
+
* not applied to `fetch` inside middleware, and the site's `/api/revalidate` handler runs
|
|
375
|
+
* in a different isolate from this one — so `revalidateTag('redirects')` cannot clear what
|
|
376
|
+
* is cached here. The platform still tags and pings, because that is correct the moment
|
|
377
|
+
* anything outside middleware reads the snapshot, but nothing in this file depends on it.
|
|
378
|
+
*
|
|
379
|
+
* **A failed refresh keeps serving the last good set.** Never falling through to no
|
|
380
|
+
* redirects is the whole point: a transient platform blip would otherwise 404 the entire
|
|
381
|
+
* migrated URL surface for as long as it lasted, which is the exact failure this feature
|
|
382
|
+
* exists to prevent.
|
|
383
|
+
*/
|
|
384
|
+
export const createRedirectMiddleware = ({
|
|
385
|
+
apiKey = process.env.PAYLOAD_API_KEY,
|
|
386
|
+
fetchImpl = globalThis.fetch,
|
|
387
|
+
now = () => Date.now(),
|
|
388
|
+
platformUrl = process.env.PLATFORM_URL,
|
|
389
|
+
snapshot,
|
|
390
|
+
ttlSeconds = 60,
|
|
391
|
+
}: {
|
|
392
|
+
apiKey?: string
|
|
393
|
+
/** Injected for tests. Defaults to the platform fetch. */
|
|
394
|
+
fetchImpl?: typeof globalThis.fetch
|
|
395
|
+
now?: () => number
|
|
396
|
+
platformUrl?: string
|
|
397
|
+
/** The build-time snapshot. This is the floor the site never drops below. */
|
|
398
|
+
snapshot: RedirectSnapshot
|
|
399
|
+
ttlSeconds?: number
|
|
400
|
+
}) => {
|
|
401
|
+
/*
|
|
402
|
+
* Module scope, so it survives between requests on a warm isolate and costs one compile
|
|
403
|
+
* per refresh rather than one per request. A cold isolate starts from the baked snapshot,
|
|
404
|
+
* which is why the site is correct before the first fetch ever completes.
|
|
405
|
+
*/
|
|
406
|
+
let compiled = compileRedirects(snapshot.rules ?? [])
|
|
407
|
+
let refreshedAt = 0
|
|
408
|
+
let inFlight: Promise<void> | null = null
|
|
409
|
+
|
|
410
|
+
const refresh = async (): Promise<void> => {
|
|
411
|
+
if (!platformUrl || !apiKey || !fetchImpl) return
|
|
412
|
+
try {
|
|
413
|
+
const response = await fetchImpl(`${platformUrl.replace(/\/$/, '')}/api/u90/redirects`, {
|
|
414
|
+
headers: { Authorization: `users API-Key ${apiKey}` },
|
|
415
|
+
// Tagged for the sake of any non-middleware caller; see the docblock on why the
|
|
416
|
+
// TTL above is what actually keeps this fresh.
|
|
417
|
+
...({ next: { tags: ['redirects'] } } as Record<string, unknown>),
|
|
418
|
+
})
|
|
419
|
+
if (!response.ok) return
|
|
420
|
+
const body = (await response.json()) as RedirectSnapshot
|
|
421
|
+
if (!Array.isArray(body?.rules)) return
|
|
422
|
+
compiled = compileRedirects(body.rules)
|
|
423
|
+
refreshedAt = now()
|
|
424
|
+
} catch {
|
|
425
|
+
/*
|
|
426
|
+
* Swallowed, and `refreshedAt` deliberately left alone so the next request retries
|
|
427
|
+
* rather than waiting out another full TTL. `compiled` still holds the last good set.
|
|
428
|
+
*/
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return async function middleware(request: MinimalRequest): Promise<MinimalResponse> {
|
|
433
|
+
const url = request.nextUrl
|
|
434
|
+
? { pathname: request.nextUrl.pathname, search: request.nextUrl.search }
|
|
435
|
+
: (() => {
|
|
436
|
+
const parsed = new URL(request.url)
|
|
437
|
+
return { pathname: parsed.pathname, search: parsed.search }
|
|
438
|
+
})()
|
|
439
|
+
|
|
440
|
+
if (isExcluded(url.pathname)) return undefined
|
|
441
|
+
|
|
442
|
+
/*
|
|
443
|
+
* Refreshed without awaiting when the set is merely stale, so a visitor never pays for
|
|
444
|
+
* the platform round trip — they get the last good answer and the next visitor gets the
|
|
445
|
+
* new one. `inFlight` collapses the stampede a burst of traffic would otherwise cause
|
|
446
|
+
* against a cold cache.
|
|
447
|
+
*/
|
|
448
|
+
if (now() - refreshedAt > ttlSeconds * 1000 && !inFlight) {
|
|
449
|
+
inFlight = refresh().finally(() => {
|
|
450
|
+
inFlight = null
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const match = matchRedirect(compiled, url.pathname + url.search)
|
|
455
|
+
if (!match) return undefined
|
|
456
|
+
|
|
457
|
+
if (match.status === 410) {
|
|
458
|
+
/*
|
|
459
|
+
* A body, because a bare 410 renders as the browser's own error page. The status code
|
|
460
|
+
* is what search engines act on; §11 Q4 leaves a designed page open, and this is the
|
|
461
|
+
* honest minimum until it is answered.
|
|
462
|
+
*/
|
|
463
|
+
return new Response('Gone', {
|
|
464
|
+
status: 410,
|
|
465
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return new Response(null, {
|
|
470
|
+
status: match.status,
|
|
471
|
+
headers: { location: match.destination },
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import { FORMAT, type RichTextValue } from './lexical'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A stored rich text document, as elements.
|
|
7
|
+
*
|
|
8
|
+
* The piece that was missing, and the reason `pageBody.body` is a textarea and the legal pages
|
|
9
|
+
* were twelve sections: until this existed a site could declare a `richText` field and had no
|
|
10
|
+
* way to draw one.
|
|
11
|
+
*
|
|
12
|
+
* **Unknown node types render their children.** A document authored by an editor that knows a
|
|
13
|
+
* node this reader does not must still read as prose. Rendering nothing would turn a client's
|
|
14
|
+
* published privacy policy into a blank page the first time the vocabulary grew, which is a
|
|
15
|
+
* worse outcome than an unstyled paragraph by any measure that matters.
|
|
16
|
+
*
|
|
17
|
+
* **The mark order is fixed and load-bearing.** `strong > em > s > u > code`, always, so the
|
|
18
|
+
* same bitfield always produces the same DOM — `lexicalDom.ts` inverts this exact nesting.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Outermost first. `lexicalDom.ts` unwraps in this order; the two must not drift. */
|
|
22
|
+
const MARKS: [number, keyof React.JSX.IntrinsicElements][] = [
|
|
23
|
+
[FORMAT.bold, 'strong'],
|
|
24
|
+
[FORMAT.italic, 'em'],
|
|
25
|
+
[FORMAT.strikethrough, 's'],
|
|
26
|
+
[FORMAT.underline, 'u'],
|
|
27
|
+
[FORMAT.code, 'code'],
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
type Node = Record<string, unknown>
|
|
31
|
+
|
|
32
|
+
const children = (node: Node, key: string): React.ReactNode => {
|
|
33
|
+
const list = Array.isArray(node.children) ? (node.children as Node[]) : []
|
|
34
|
+
return list.map((child, index) => draw(child, `${key}.${index}`))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const draw = (node: Node, key: string): React.ReactNode => {
|
|
38
|
+
if (!node || typeof node !== 'object') return null
|
|
39
|
+
|
|
40
|
+
switch (node.type) {
|
|
41
|
+
case 'text': {
|
|
42
|
+
const format = typeof node.format === 'number' ? node.format : 0
|
|
43
|
+
// reduceRight: MARKS is outermost-first, so the last one applied (bold) must be the
|
|
44
|
+
// *first* processed if we walked left-to-right, or — equivalently and simpler — the
|
|
45
|
+
// *last* processed walking right-to-left, so it ends up as the outermost wrap.
|
|
46
|
+
return MARKS.reduceRight<React.ReactNode>(
|
|
47
|
+
(inner, [bit, Tag]) =>
|
|
48
|
+
format & bit ? React.createElement(Tag, { key }, inner) : inner,
|
|
49
|
+
typeof node.singleKey === 'string'
|
|
50
|
+
? <span data-single-key={node.singleKey} contentEditable={false}>{String(node.text ?? '')}</span>
|
|
51
|
+
: String(node.text ?? ''),
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
case 'linebreak':
|
|
55
|
+
return <br key={key} />
|
|
56
|
+
case 'link': {
|
|
57
|
+
const url = (node.fields as { url?: unknown } | undefined)?.url
|
|
58
|
+
return (
|
|
59
|
+
<a href={typeof url === 'string' ? url : '#'} key={key}>
|
|
60
|
+
{children(node, key)}
|
|
61
|
+
</a>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
case 'paragraph':
|
|
65
|
+
return <p key={key}>{children(node, key)}</p>
|
|
66
|
+
case 'heading':
|
|
67
|
+
/*
|
|
68
|
+
* `h1` is offered because a client asked for it. The page's own title is a separate
|
|
69
|
+
* element outside this field, so a document that opens with its own h1 is now the
|
|
70
|
+
* client's call rather than the platform's.
|
|
71
|
+
*/
|
|
72
|
+
if (node.tag === 'h1') return <h1 key={key}>{children(node, key)}</h1>
|
|
73
|
+
if (node.tag === 'h3') return <h3 key={key}>{children(node, key)}</h3>
|
|
74
|
+
return <h2 key={key}>{children(node, key)}</h2>
|
|
75
|
+
case 'list':
|
|
76
|
+
return node.listType === 'number' ? (
|
|
77
|
+
<ol key={key}>{children(node, key)}</ol>
|
|
78
|
+
) : (
|
|
79
|
+
<ul key={key}>{children(node, key)}</ul>
|
|
80
|
+
)
|
|
81
|
+
case 'listitem':
|
|
82
|
+
return <li key={key}>{children(node, key)}</li>
|
|
83
|
+
default:
|
|
84
|
+
// See the note above: prose, never a blank page.
|
|
85
|
+
return <React.Fragment key={key}>{children(node, key)}</React.Fragment>
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const RichText = ({
|
|
90
|
+
className,
|
|
91
|
+
value,
|
|
92
|
+
}: {
|
|
93
|
+
className?: string
|
|
94
|
+
value: unknown
|
|
95
|
+
}): React.ReactElement | null => {
|
|
96
|
+
const root = (value as RichTextValue | null)?.root
|
|
97
|
+
if (!root || !Array.isArray(root.children)) return null
|
|
98
|
+
|
|
99
|
+
const body = (root.children as unknown as Node[]).map((node, index) =>
|
|
100
|
+
draw(node, String(index)),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return className ? <div className={className}>{body}</div> : <>{body}</>
|
|
104
|
+
}
|