@unitninety/site-kit 0.0.0-stage → 1.6.2

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.
Files changed (206) hide show
  1. package/README.md +30 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/DraftEntries.tsx +41 -0
  5. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  6. package/src/bridge/U90Bridge.tsx +54 -0
  7. package/src/bridge/caret.ts +317 -0
  8. package/src/bridge/client.tsx +66 -0
  9. package/src/bridge/createBridge.ts +1015 -0
  10. package/src/bridge/hooks.tsx +56 -0
  11. package/src/bridge/index.ts +9 -0
  12. package/src/bridge/lexical.ts +177 -0
  13. package/src/bridge/lexicalDom.ts +212 -0
  14. package/src/bridge/preserveBlockMoves.ts +88 -0
  15. package/src/bridge/preview.ts +179 -0
  16. package/src/bridge/protocol.ts +273 -0
  17. package/src/bridge/registry.ts +30 -0
  18. package/src/bridge/styles.ts +137 -0
  19. package/src/components/DateText.tsx +9 -0
  20. package/src/components/Entries.tsx +16 -0
  21. package/src/components/File.tsx +9 -0
  22. package/src/components/Image.tsx +30 -0
  23. package/src/components/Link.tsx +24 -0
  24. package/src/components/NumberText.tsx +9 -0
  25. package/src/components/RichText.tsx +47 -0
  26. package/src/components/Text.tsx +15 -0
  27. package/src/components/Video.tsx +12 -0
  28. package/src/components/VideoPlayback.tsx +19 -0
  29. package/src/components/draft.ts +9 -0
  30. package/src/components/imageProps.ts +26 -0
  31. package/src/components/index.ts +13 -0
  32. package/src/components/videoProps.ts +6 -0
  33. package/src/elements/containers/group.ts +33 -0
  34. package/src/elements/containers/list.ts +31 -0
  35. package/src/elements/containers/zone.ts +41 -0
  36. package/src/elements/declaration.ts +118 -0
  37. package/src/elements/define.ts +195 -0
  38. package/src/elements/ids.ts +50 -0
  39. package/src/elements/index.ts +35 -0
  40. package/src/elements/jsonSchema.ts +36 -0
  41. package/src/elements/leaf.ts +98 -0
  42. package/src/elements/leaves/boolean.ts +30 -0
  43. package/src/elements/leaves/choice.ts +53 -0
  44. package/src/elements/leaves/date.ts +39 -0
  45. package/src/elements/leaves/file.ts +56 -0
  46. package/src/elements/leaves/image.ts +160 -0
  47. package/src/elements/leaves/link.ts +149 -0
  48. package/src/elements/leaves/number.ts +52 -0
  49. package/src/elements/leaves/query.ts +101 -0
  50. package/src/elements/leaves/relation.ts +69 -0
  51. package/src/elements/leaves/rich-text.ts +155 -0
  52. package/src/elements/leaves/selection.ts +40 -0
  53. package/src/elements/leaves/single.ts +29 -0
  54. package/src/elements/leaves/text.ts +103 -0
  55. package/src/elements/leaves/video.ts +108 -0
  56. package/src/elements/ops/expand.ts +94 -0
  57. package/src/elements/ops/overlay.ts +59 -0
  58. package/src/elements/ops/refs.ts +45 -0
  59. package/src/elements/ops/seed.ts +185 -0
  60. package/src/elements/ops/text.ts +20 -0
  61. package/src/elements/ops/translatable.ts +19 -0
  62. package/src/elements/ops/upgrade.ts +43 -0
  63. package/src/elements/ops/validate.ts +56 -0
  64. package/src/elements/order.ts +10 -0
  65. package/src/elements/path.ts +43 -0
  66. package/src/elements/prefix.ts +2 -0
  67. package/src/elements/registry.ts +48 -0
  68. package/src/elements/tokens.ts +14 -0
  69. package/src/elements/walk.ts +42 -0
  70. package/src/features/client/AskAiProvider.tsx +551 -0
  71. package/src/features/client/AskAiWidget.tsx +222 -0
  72. package/src/features/client/ConsentRuntime.tsx +95 -0
  73. package/src/features/client/FeedbackProvider.tsx +231 -0
  74. package/src/features/client/FeedbackWidget.tsx +101 -0
  75. package/src/features/client/PageReporter.tsx +26 -0
  76. package/src/features/client/README.md +131 -0
  77. package/src/features/client/SiteFeatures.tsx +250 -0
  78. package/src/features/client/askAiRecovery.ts +92 -0
  79. package/src/features/client/askAiStream.ts +59 -0
  80. package/src/features/client/context.tsx +24 -0
  81. package/src/features/client/index.ts +24 -0
  82. package/src/features/client/overlay.tsx +129 -0
  83. package/src/features/client/styles.css +337 -0
  84. package/src/features/client/transport.ts +310 -0
  85. package/src/features/client/useAskAI.ts +45 -0
  86. package/src/features/client/useConsent.ts +15 -0
  87. package/src/features/client/useFeedback.ts +33 -0
  88. package/src/features/client/useSiteSearch.ts +50 -0
  89. package/src/features/client/verification.ts +141 -0
  90. package/src/features/consent/README.md +70 -0
  91. package/src/features/consent/cookieScript.ts +53 -0
  92. package/src/features/consent/custom.ts +89 -0
  93. package/src/features/consent/defaults.ts +40 -0
  94. package/src/features/consent/drivers.ts +233 -0
  95. package/src/features/consent/loader.ts +248 -0
  96. package/src/features/consent/registry.ts +398 -0
  97. package/src/features/contracts/askAi.ts +166 -0
  98. package/src/features/contracts/askAiSettings.ts +42 -0
  99. package/src/features/contracts/base.ts +107 -0
  100. package/src/features/contracts/consent.ts +150 -0
  101. package/src/features/contracts/feedback.ts +22 -0
  102. package/src/features/contracts/feedbackSettings.ts +49 -0
  103. package/src/features/contracts/index.ts +61 -0
  104. package/src/features/contracts/knowledge.ts +99 -0
  105. package/src/features/contracts/knowledgePolicy.ts +249 -0
  106. package/src/features/contracts/search.ts +24 -0
  107. package/src/features/contracts/verification.ts +52 -0
  108. package/src/features/contracts/verificationConfig.ts +9 -0
  109. package/src/features/server/ConsentDefaults.tsx +15 -0
  110. package/src/features/server/README.md +185 -0
  111. package/src/features/server/gateway.ts +318 -0
  112. package/src/features/server/http.ts +179 -0
  113. package/src/features/server/index.ts +6 -0
  114. package/src/features/server/session.ts +23 -0
  115. package/src/features/server/verification.ts +39 -0
  116. package/src/features/server/verifyVisitor.ts +69 -0
  117. package/src/languages/config.d.mts +5 -0
  118. package/src/languages/config.mjs +53 -0
  119. package/src/languages/decide.ts +72 -0
  120. package/src/languages/index.ts +4 -0
  121. package/src/languages/proxy.ts +108 -0
  122. package/src/languages/routing.ts +93 -0
  123. package/src/languages/wire.ts +19 -0
  124. package/src/legacy/bridge.tsx +1987 -0
  125. package/src/legacy/caret.ts +246 -0
  126. package/src/legacy/cli.mjs +189 -0
  127. package/src/legacy/content.ts +993 -0
  128. package/src/legacy/contract.ts +664 -0
  129. package/src/legacy/defineBlocks.ts +1602 -0
  130. package/src/legacy/defineSite.ts +623 -0
  131. package/src/legacy/defineSite.types.ts +270 -0
  132. package/src/legacy/draft.tsx +128 -0
  133. package/src/legacy/image.ts +433 -0
  134. package/src/legacy/index.ts +209 -0
  135. package/src/legacy/language-switcher.tsx +58 -0
  136. package/src/legacy/languageHints.ts +153 -0
  137. package/src/legacy/languages/fields.ts +131 -0
  138. package/src/legacy/languages/hash.ts +98 -0
  139. package/src/legacy/languages/health.ts +97 -0
  140. package/src/legacy/languages/identity.ts +247 -0
  141. package/src/legacy/languages/links.ts +81 -0
  142. package/src/legacy/languages/resolution.ts +56 -0
  143. package/src/legacy/languages/routing.ts +169 -0
  144. package/src/legacy/languages/runtime.ts +216 -0
  145. package/src/legacy/languages/seo.ts +95 -0
  146. package/src/legacy/languages/settings.ts +211 -0
  147. package/src/legacy/languages/types.ts +85 -0
  148. package/src/legacy/languages.ts +8 -0
  149. package/src/legacy/lexical.ts +170 -0
  150. package/src/legacy/lexicalDom.ts +166 -0
  151. package/src/legacy/media.ts +272 -0
  152. package/src/legacy/nav.ts +176 -0
  153. package/src/legacy/preview.ts +79 -0
  154. package/src/legacy/redirects.ts +474 -0
  155. package/src/legacy/richText.tsx +104 -0
  156. package/src/legacy/routes.ts +414 -0
  157. package/src/legacy/seo.ts +327 -0
  158. package/src/legacy/shape.ts +412 -0
  159. package/src/legacy/uploads.ts +233 -0
  160. package/src/legacy/verification.ts +42 -0
  161. package/src/preview-lock/contracts.ts +42 -0
  162. package/src/preview-lock/index.ts +3 -0
  163. package/src/preview-lock/screen.tsx +244 -0
  164. package/src/preview-lock/server.ts +416 -0
  165. package/src/redirects/config.d.mts +5 -0
  166. package/src/redirects/config.mjs +22 -0
  167. package/src/redirects/index.ts +3 -0
  168. package/src/redirects/location.ts +31 -0
  169. package/src/redirects/match.ts +199 -0
  170. package/src/redirects/proxy.ts +91 -0
  171. package/src/redirects/wire.ts +30 -0
  172. package/src/seo/jsonLd.ts +70 -0
  173. package/src/seo/metadata.ts +127 -0
  174. package/src/seo/robots.ts +14 -0
  175. package/src/seo/sitemap.ts +22 -0
  176. package/src/seo/title.ts +22 -0
  177. package/src/server/adapters.ts +73 -0
  178. package/src/server/createU90.ts +96 -0
  179. package/src/server/handlers.ts +164 -0
  180. package/src/server/index.ts +6 -0
  181. package/src/server/read.ts +207 -0
  182. package/src/server/render.tsx +46 -0
  183. package/src/server/routes.tsx +171 -0
  184. package/src/site/assemble.ts +109 -0
  185. package/src/site/defineSite.ts +132 -0
  186. package/src/site/documents.ts +18 -0
  187. package/src/site/fields.ts +59 -0
  188. package/src/site/index.ts +14 -0
  189. package/src/site/replies.ts +118 -0
  190. package/src/site/store.ts +45 -0
  191. package/src/site/types.ts +75 -0
  192. package/src/site/version.ts +1 -0
  193. package/src/site/wire.ts +125 -0
  194. package/src/switcher/LanguageSwitcher.tsx +42 -0
  195. package/src/test-preview/README.md +90 -0
  196. package/src/test-preview/binding.ts +54 -0
  197. package/src/test-preview/bridge.ts +16 -0
  198. package/src/test-preview/guard.ts +186 -0
  199. package/src/test-preview/media.ts +23 -0
  200. package/src/test-preview/protocol.d.mts +67 -0
  201. package/src/test-preview/protocol.mjs +222 -0
  202. package/src/test-preview/runtime.ts +168 -0
  203. package/src/test-preview/server.ts +10 -0
  204. package/src/testing/fake.ts +148 -0
  205. package/src/testing/index.ts +2 -0
  206. 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
+ }