@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.
Files changed (204) hide show
  1. package/README.md +12 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  5. package/src/bridge/U90Bridge.tsx +54 -0
  6. package/src/bridge/caret.ts +317 -0
  7. package/src/bridge/client.tsx +66 -0
  8. package/src/bridge/createBridge.ts +1010 -0
  9. package/src/bridge/hooks.tsx +56 -0
  10. package/src/bridge/index.ts +9 -0
  11. package/src/bridge/lexical.ts +177 -0
  12. package/src/bridge/lexicalDom.ts +212 -0
  13. package/src/bridge/preserveBlockMoves.ts +88 -0
  14. package/src/bridge/preview.ts +179 -0
  15. package/src/bridge/protocol.ts +271 -0
  16. package/src/bridge/registry.ts +27 -0
  17. package/src/bridge/styles.ts +137 -0
  18. package/src/components/DateText.tsx +9 -0
  19. package/src/components/File.tsx +9 -0
  20. package/src/components/Image.tsx +30 -0
  21. package/src/components/Link.tsx +24 -0
  22. package/src/components/NumberText.tsx +9 -0
  23. package/src/components/RichText.tsx +47 -0
  24. package/src/components/Text.tsx +15 -0
  25. package/src/components/Video.tsx +12 -0
  26. package/src/components/VideoPlayback.tsx +19 -0
  27. package/src/components/draft.ts +9 -0
  28. package/src/components/imageProps.ts +26 -0
  29. package/src/components/index.ts +12 -0
  30. package/src/components/videoProps.ts +6 -0
  31. package/src/elements/containers/group.ts +33 -0
  32. package/src/elements/containers/list.ts +31 -0
  33. package/src/elements/containers/zone.ts +41 -0
  34. package/src/elements/declaration.ts +118 -0
  35. package/src/elements/define.ts +195 -0
  36. package/src/elements/ids.ts +50 -0
  37. package/src/elements/index.ts +35 -0
  38. package/src/elements/jsonSchema.ts +36 -0
  39. package/src/elements/leaf.ts +98 -0
  40. package/src/elements/leaves/boolean.ts +30 -0
  41. package/src/elements/leaves/choice.ts +53 -0
  42. package/src/elements/leaves/date.ts +39 -0
  43. package/src/elements/leaves/file.ts +56 -0
  44. package/src/elements/leaves/image.ts +160 -0
  45. package/src/elements/leaves/link.ts +149 -0
  46. package/src/elements/leaves/number.ts +52 -0
  47. package/src/elements/leaves/query.ts +101 -0
  48. package/src/elements/leaves/relation.ts +69 -0
  49. package/src/elements/leaves/rich-text.ts +155 -0
  50. package/src/elements/leaves/selection.ts +40 -0
  51. package/src/elements/leaves/single.ts +29 -0
  52. package/src/elements/leaves/text.ts +103 -0
  53. package/src/elements/leaves/video.ts +108 -0
  54. package/src/elements/ops/expand.ts +94 -0
  55. package/src/elements/ops/overlay.ts +59 -0
  56. package/src/elements/ops/refs.ts +45 -0
  57. package/src/elements/ops/seed.ts +185 -0
  58. package/src/elements/ops/text.ts +20 -0
  59. package/src/elements/ops/translatable.ts +19 -0
  60. package/src/elements/ops/upgrade.ts +43 -0
  61. package/src/elements/ops/validate.ts +56 -0
  62. package/src/elements/order.ts +10 -0
  63. package/src/elements/path.ts +43 -0
  64. package/src/elements/prefix.ts +2 -0
  65. package/src/elements/registry.ts +48 -0
  66. package/src/elements/tokens.ts +14 -0
  67. package/src/elements/walk.ts +42 -0
  68. package/src/features/client/AskAiProvider.tsx +551 -0
  69. package/src/features/client/AskAiWidget.tsx +222 -0
  70. package/src/features/client/ConsentRuntime.tsx +95 -0
  71. package/src/features/client/FeedbackProvider.tsx +231 -0
  72. package/src/features/client/FeedbackWidget.tsx +101 -0
  73. package/src/features/client/PageReporter.tsx +26 -0
  74. package/src/features/client/README.md +131 -0
  75. package/src/features/client/SiteFeatures.tsx +250 -0
  76. package/src/features/client/askAiRecovery.ts +92 -0
  77. package/src/features/client/askAiStream.ts +59 -0
  78. package/src/features/client/context.tsx +24 -0
  79. package/src/features/client/index.ts +24 -0
  80. package/src/features/client/overlay.tsx +129 -0
  81. package/src/features/client/styles.css +337 -0
  82. package/src/features/client/transport.ts +310 -0
  83. package/src/features/client/useAskAI.ts +45 -0
  84. package/src/features/client/useConsent.ts +15 -0
  85. package/src/features/client/useFeedback.ts +33 -0
  86. package/src/features/client/useSiteSearch.ts +50 -0
  87. package/src/features/client/verification.ts +141 -0
  88. package/src/features/consent/README.md +70 -0
  89. package/src/features/consent/cookieScript.ts +53 -0
  90. package/src/features/consent/custom.ts +89 -0
  91. package/src/features/consent/defaults.ts +40 -0
  92. package/src/features/consent/drivers.ts +233 -0
  93. package/src/features/consent/loader.ts +248 -0
  94. package/src/features/consent/registry.ts +398 -0
  95. package/src/features/contracts/askAi.ts +166 -0
  96. package/src/features/contracts/askAiSettings.ts +42 -0
  97. package/src/features/contracts/base.ts +107 -0
  98. package/src/features/contracts/consent.ts +150 -0
  99. package/src/features/contracts/feedback.ts +22 -0
  100. package/src/features/contracts/feedbackSettings.ts +49 -0
  101. package/src/features/contracts/index.ts +61 -0
  102. package/src/features/contracts/knowledge.ts +99 -0
  103. package/src/features/contracts/knowledgePolicy.ts +249 -0
  104. package/src/features/contracts/search.ts +24 -0
  105. package/src/features/contracts/verification.ts +52 -0
  106. package/src/features/contracts/verificationConfig.ts +9 -0
  107. package/src/features/server/ConsentDefaults.tsx +15 -0
  108. package/src/features/server/README.md +185 -0
  109. package/src/features/server/gateway.ts +318 -0
  110. package/src/features/server/http.ts +179 -0
  111. package/src/features/server/index.ts +6 -0
  112. package/src/features/server/session.ts +23 -0
  113. package/src/features/server/verification.ts +39 -0
  114. package/src/features/server/verifyVisitor.ts +69 -0
  115. package/src/languages/config.d.mts +5 -0
  116. package/src/languages/config.mjs +53 -0
  117. package/src/languages/decide.ts +72 -0
  118. package/src/languages/index.ts +4 -0
  119. package/src/languages/proxy.ts +108 -0
  120. package/src/languages/routing.ts +93 -0
  121. package/src/languages/wire.ts +19 -0
  122. package/src/legacy/bridge.tsx +1987 -0
  123. package/src/legacy/caret.ts +246 -0
  124. package/src/legacy/cli.mjs +189 -0
  125. package/src/legacy/content.ts +993 -0
  126. package/src/legacy/contract.ts +664 -0
  127. package/src/legacy/defineBlocks.ts +1602 -0
  128. package/src/legacy/defineSite.ts +623 -0
  129. package/src/legacy/defineSite.types.ts +270 -0
  130. package/src/legacy/draft.tsx +128 -0
  131. package/src/legacy/image.ts +433 -0
  132. package/src/legacy/index.ts +209 -0
  133. package/src/legacy/language-switcher.tsx +58 -0
  134. package/src/legacy/languageHints.ts +153 -0
  135. package/src/legacy/languages/fields.ts +131 -0
  136. package/src/legacy/languages/hash.ts +98 -0
  137. package/src/legacy/languages/health.ts +97 -0
  138. package/src/legacy/languages/identity.ts +247 -0
  139. package/src/legacy/languages/links.ts +81 -0
  140. package/src/legacy/languages/resolution.ts +56 -0
  141. package/src/legacy/languages/routing.ts +169 -0
  142. package/src/legacy/languages/runtime.ts +216 -0
  143. package/src/legacy/languages/seo.ts +95 -0
  144. package/src/legacy/languages/settings.ts +211 -0
  145. package/src/legacy/languages/types.ts +85 -0
  146. package/src/legacy/languages.ts +8 -0
  147. package/src/legacy/lexical.ts +170 -0
  148. package/src/legacy/lexicalDom.ts +166 -0
  149. package/src/legacy/media.ts +272 -0
  150. package/src/legacy/nav.ts +176 -0
  151. package/src/legacy/preview.ts +79 -0
  152. package/src/legacy/redirects.ts +474 -0
  153. package/src/legacy/richText.tsx +104 -0
  154. package/src/legacy/routes.ts +414 -0
  155. package/src/legacy/seo.ts +327 -0
  156. package/src/legacy/shape.ts +412 -0
  157. package/src/legacy/uploads.ts +233 -0
  158. package/src/legacy/verification.ts +42 -0
  159. package/src/preview-lock/contracts.ts +42 -0
  160. package/src/preview-lock/index.ts +3 -0
  161. package/src/preview-lock/screen.tsx +244 -0
  162. package/src/preview-lock/server.ts +416 -0
  163. package/src/redirects/config.d.mts +5 -0
  164. package/src/redirects/config.mjs +22 -0
  165. package/src/redirects/index.ts +3 -0
  166. package/src/redirects/location.ts +31 -0
  167. package/src/redirects/match.ts +199 -0
  168. package/src/redirects/proxy.ts +91 -0
  169. package/src/redirects/wire.ts +30 -0
  170. package/src/seo/jsonLd.ts +70 -0
  171. package/src/seo/metadata.ts +127 -0
  172. package/src/seo/robots.ts +14 -0
  173. package/src/seo/sitemap.ts +22 -0
  174. package/src/seo/title.ts +22 -0
  175. package/src/server/adapters.ts +73 -0
  176. package/src/server/createU90.ts +96 -0
  177. package/src/server/handlers.ts +164 -0
  178. package/src/server/index.ts +6 -0
  179. package/src/server/read.ts +207 -0
  180. package/src/server/render.tsx +46 -0
  181. package/src/server/routes.tsx +171 -0
  182. package/src/site/assemble.ts +109 -0
  183. package/src/site/defineSite.ts +132 -0
  184. package/src/site/documents.ts +18 -0
  185. package/src/site/fields.ts +59 -0
  186. package/src/site/index.ts +14 -0
  187. package/src/site/replies.ts +118 -0
  188. package/src/site/store.ts +45 -0
  189. package/src/site/types.ts +75 -0
  190. package/src/site/version.ts +1 -0
  191. package/src/site/wire.ts +125 -0
  192. package/src/switcher/LanguageSwitcher.tsx +42 -0
  193. package/src/test-preview/README.md +90 -0
  194. package/src/test-preview/binding.ts +54 -0
  195. package/src/test-preview/bridge.ts +16 -0
  196. package/src/test-preview/guard.ts +186 -0
  197. package/src/test-preview/media.ts +23 -0
  198. package/src/test-preview/protocol.d.mts +67 -0
  199. package/src/test-preview/protocol.mjs +222 -0
  200. package/src/test-preview/runtime.ts +168 -0
  201. package/src/test-preview/server.ts +10 -0
  202. package/src/testing/fake.ts +148 -0
  203. package/src/testing/index.ts +2 -0
  204. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,414 @@
1
+ import { canonicalLocale } from './languages/settings'
2
+ import { localePath } from './languages/routing'
3
+ import type { createLanguageRuntime } from './languages/runtime'
4
+ import { createManifest, type BlockSpec } from './defineBlocks'
5
+ import { PREVIEW_MAX_AGE_SECONDS, PREVIEW_TOKEN_COOKIE, PREVIEW_UNTIL_COOKIE } from './preview'
6
+ import { manifestFrom, type Site } from './defineSite'
7
+ import type { EditorBlock, ManifestCollection, PageDeclaration, SeedSection } from './contract'
8
+
9
+ /**
10
+ * The four routes every UnitNinety site mounts.
11
+ *
12
+ * Each is a factory returning a Next route handler, so a site's route file is one line
13
+ * and the logic is versioned here. These used to be copy-pasted per site, which meant
14
+ * a fix to one of them existed in exactly one repo and the next client silently got the
15
+ * old version.
16
+ *
17
+ * Nothing here needs the platform's API key — these are the *inbound* half of the
18
+ * contract, authenticated with the tenant's `revalidateSecret`.
19
+ */
20
+
21
+ /** Constant-time compare, so a wrong secret can't be found a byte at a time. */
22
+ const secretMatches = (provided: string, expected: string): boolean => {
23
+ if (provided.length !== expected.length) return false
24
+ let mismatch = 0
25
+ for (let index = 0; index < provided.length; index += 1) {
26
+ mismatch |= provided.charCodeAt(index) ^ expected.charCodeAt(index)
27
+ }
28
+ return mismatch === 0
29
+ }
30
+
31
+ const bearer = (request: Request): string => {
32
+ const header = request.headers.get('authorization') ?? ''
33
+ return header.startsWith('Bearer ') ? header.slice(7) : ''
34
+ }
35
+
36
+ /**
37
+ * `GET /api/u90/manifest` — publish this site's content schema.
38
+ *
39
+ * export const GET = createManifestRoute({ blocks, pages })
40
+ *
41
+ * Authenticated, because the schema describes the client's editing surface and there is
42
+ * no reason for it to be public. The platform is the only caller.
43
+ */
44
+ export const createManifestRoute = ({
45
+ blocks,
46
+ languageRuntime,
47
+ collections,
48
+ newPage,
49
+ pages,
50
+ routesAllPages,
51
+ secret = process.env.REVALIDATE_SECRET,
52
+ seed,
53
+ site,
54
+ }: {
55
+ languageRuntime?: ReturnType<typeof createLanguageRuntime>
56
+ blocks?: EditorBlock[]
57
+ /** Repeatable record types, from `defineCollections`. Their seeds ride along. */
58
+ collections?: ManifestCollection[]
59
+ /** The sections a client-added page starts with. See `SiteManifest.newPage`. */
60
+ newPage?: SeedSection[]
61
+ pages?: PageDeclaration[]
62
+ /** True when a catch-all serves every stored slug. See `SiteManifest.routesAllPages`. */
63
+ routesAllPages?: boolean
64
+ secret?: string
65
+ /**
66
+ * The spec passed to `defineBlocks`, so the manifest carries the site's committed
67
+ * copy as seed content. The platform stores it once, on a tenant's first pair —
68
+ * without it a fresh tenant's editor is a page of blanks while the site renders
69
+ * perfectly, because every component falls back to its committed copy.
70
+ */
71
+ seed?: BlockSpec
72
+ /** A `defineSite` result. Supersedes `blocks`, `collections` and `seed`; pass one or the other. */
73
+ site?: Site<unknown>
74
+ }) => {
75
+ if (site) {
76
+ const legacyKey = blocks ? 'blocks' : collections ? 'collections' : seed ? 'seed' : undefined
77
+ if (legacyKey) {
78
+ throw new Error(
79
+ `createManifestRoute takes either \`site\` or \`${legacyKey}\`, not both. A site already carries its blocks, collections and seed.`,
80
+ )
81
+ }
82
+ } else if (!blocks) {
83
+ /*
84
+ * Neither form, which is the other half of the guard above and fails the same way:
85
+ * quietly. `blocks` is optional only so that `site` can replace it, so a route with
86
+ * neither serves `blocks: []` — a manifest the platform accepts and renders as an
87
+ * editor with nothing in it, which a client reads as their content having vanished.
88
+ * Both keys being absent is a mistake at the call site, so it is raised where the
89
+ * call is, at module load, rather than on the platform's first fetch.
90
+ *
91
+ * This also catches a legacy call that names `collections` or `seed` and forgets
92
+ * `blocks`, which used to serve an empty block list. That is the same empty editor, so
93
+ * the error is right — but a site that really does publish no blocks has to be able to
94
+ * say so, which is why the message names `blocks: []` rather than only the two keys.
95
+ */
96
+ throw new Error(
97
+ "createManifestRoute needs either `site` (from defineSite) or `blocks` (from defineBlocks). With neither it publishes an empty schema, and the client's editor opens with no fields at all. If this site genuinely has no blocks, say so with `blocks: []`.",
98
+ )
99
+ }
100
+
101
+ return async function GET(request: Request): Promise<Response> {
102
+ if (!secret) {
103
+ // Misconfiguration, not an auth failure — say so, because the platform surfaces
104
+ // this string to an operator during pairing.
105
+ return Response.json({ error: 'REVALIDATE_SECRET is not set on this site.' }, { status: 500 })
106
+ }
107
+
108
+ if (!secretMatches(bearer(request), secret)) {
109
+ return Response.json({ error: 'Unauthorized' }, { status: 401 })
110
+ }
111
+
112
+ return Response.json(
113
+ {
114
+ ...(site
115
+ ? manifestFrom({ newPage, pages, routesAllPages, site })
116
+ : createManifest({
117
+ blocks: blocks ?? [],
118
+ collections,
119
+ newPage,
120
+ pages,
121
+ routesAllPages,
122
+ seed,
123
+ })),
124
+ ...(languageRuntime?.languageVersion === 1 ? { languageVersion: 1 } : {}),
125
+ },
126
+ {
127
+ // The platform does its own tag-based caching; a cached response here would just
128
+ // make a deploy's new schema invisible for longer.
129
+ headers: { 'Cache-Control': 'no-store' },
130
+ },
131
+ )
132
+ }
133
+ }
134
+
135
+ /**
136
+ * `POST /api/revalidate` — the platform telling this site something published.
137
+ *
138
+ * export const POST = createRevalidateRoute({ revalidateTag })
139
+ *
140
+ * `revalidateTag` is injected rather than imported, because it comes from
141
+ * `next/cache` in the consuming app and this package must not pin a Next version.
142
+ *
143
+ * Note the platform's POST is fire-and-forget with a short timeout: this route being
144
+ * down means a stale page, never a broken dashboard.
145
+ */
146
+ export const createRevalidateRoute = ({
147
+ revalidateTag,
148
+ secret = process.env.REVALIDATE_SECRET,
149
+ }: {
150
+ /**
151
+ * Next's own `revalidateTag`. Both versions satisfy this: Next 16 requires a
152
+ * cache profile and types it as `string | CacheLifeConfig`, Next 15 takes the
153
+ * tag alone.
154
+ *
155
+ * `...rest: never[]` rather than `profile?: unknown` because the check runs the
156
+ * other way round — a function whose parameter is narrower than `unknown` is
157
+ * *not* assignable to one declaring `unknown`, so Next 16's signature was
158
+ * rejected outright. `never` accepts any parameter type, which is what
159
+ * "whatever your Next version wants" actually means in the type system.
160
+ */
161
+ revalidateTag: (tag: string, ...rest: never[]) => unknown
162
+ secret?: string
163
+ }) =>
164
+ async function POST(request: Request): Promise<Response> {
165
+ const body = (await request.json().catch(() => ({}))) as {
166
+ collection?: string
167
+ secret?: string
168
+ slug?: null | string
169
+ }
170
+
171
+ // Sent as a header *and* in the body, so it can be checked before parsing.
172
+ const provided = request.headers.get('x-revalidate-secret') ?? body.secret ?? ''
173
+
174
+ if (!secret || !secretMatches(provided, secret)) {
175
+ return Response.json({ error: 'Bad secret' }, { status: 401 })
176
+ }
177
+
178
+ const { collection, slug } = body
179
+ if (!collection) {
180
+ return Response.json({ error: 'No collection given' }, { status: 400 })
181
+ }
182
+
183
+ /*
184
+ * `{ expire: 0 }` so the purged entry is refetched on the next request rather than
185
+ * after a grace period — a publish the client just pressed should be visible now.
186
+ * Next 15 ignores the second argument; Next 16 requires it.
187
+ *
188
+ * One cast, because the profile's type is `CacheLifeConfig` in Next 16 and this
189
+ * package deliberately does not depend on Next to name it.
190
+ */
191
+ const purge = revalidateTag as (tag: string, profile?: unknown) => unknown
192
+
193
+ purge(collection, { expire: 0 })
194
+ if (slug) purge(`${collection}:${slug}`, { expire: 0 })
195
+
196
+ return Response.json({ revalidated: true })
197
+ }
198
+
199
+ /**
200
+ * `GET /api/preview` — enter draft mode for one document.
201
+ *
202
+ * export const GET = createPreviewRoute({ cookies, draftMode, redirect })
203
+ *
204
+ * Next's own primitives are injected for the same reason as `revalidateTag`.
205
+ *
206
+ * The forwarded Payload token is stashed as a cookie so the page can fetch drafts *as
207
+ * the signed-in client* rather than with the site's own key. That matters: it means a
208
+ * preview shows what that person staged, and it expires with their session.
209
+ */
210
+ export const createPreviewRoute = ({
211
+ cookies,
212
+ draftMode,
213
+ onAuthorised,
214
+ previewMaxAgeSeconds = PREVIEW_MAX_AGE_SECONDS,
215
+ redirect,
216
+ sameSite = process.env.NODE_ENV === 'production' ? 'none' : 'lax',
217
+ secret = process.env.REVALIDATE_SECRET,
218
+ }: {
219
+ cookies: () => Promise<{
220
+ set: (name: string, value: string, options?: Record<string, unknown>) => void
221
+ }>
222
+ draftMode: () => Promise<{ enable: () => void }>
223
+ /**
224
+ * Run after the secret is verified and before the redirect, with the cookie store.
225
+ *
226
+ * This exists for the private-preview gate every prospect site is behind. Without
227
+ * it, an editor opening Canvas is redirected to the site root, the gate rewrites
228
+ * that to the lock screen, and the dashboard reports the site as not responding to
229
+ * the editor — the schema still loads, so it looks like a bridge fault rather than
230
+ * a locked door. Knowing `REVALIDATE_SECRET` is a stronger proof than the gate's
231
+ * own code, so a site can mint its access cookie here.
232
+ *
233
+ * Called only on an authorised request. `secure` is passed through so the cookie
234
+ * matches the `sameSite` choice below — `none` requires `secure`.
235
+ */
236
+ onAuthorised?: (
237
+ store: {
238
+ set: (name: string, value: string, options?: Record<string, unknown>) => void
239
+ },
240
+ options: { sameSite: 'lax' | 'none'; secure: boolean },
241
+ ) => Promise<void> | void
242
+ /**
243
+ * How long this preview counts for. See `./preview` for why it must count for anything.
244
+ *
245
+ * Every entry restamps it, so an editor working through Canvas never meets the edge;
246
+ * the window is there for the browser that stops asking.
247
+ */
248
+ previewMaxAgeSeconds?: number
249
+ redirect: (url: string) => never
250
+ /**
251
+ * `none` in production because the dashboard frames this site cross-origin, and a
252
+ * `lax` cookie is not sent in a third-party frame — the preview would silently show
253
+ * published content instead of the draft.
254
+ */
255
+ sameSite?: 'lax' | 'none'
256
+ secret?: string
257
+ }) =>
258
+ async function GET(request: Request): Promise<Response> {
259
+ const { searchParams } = new URL(request.url)
260
+
261
+ if (!secret || !secretMatches(searchParams.get('secret') ?? '', secret)) {
262
+ return new Response('Bad secret', { status: 401 })
263
+ }
264
+
265
+ if (searchParams.has('locale') && !canonicalLocale(searchParams.get('locale')))
266
+ return new Response('Invalid locale', { status: 400 })
267
+ if (
268
+ searchParams.has('languageRevision') &&
269
+ (!/^(0|[1-9]\d*)$/.test(searchParams.get('languageRevision') ?? '') ||
270
+ !Number.isSafeInteger(Number(searchParams.get('languageRevision'))))
271
+ )
272
+ return new Response('Invalid language revision', { status: 400 })
273
+ const draft = await draftMode()
274
+ draft.enable()
275
+
276
+ const store = await cookies()
277
+ const token = searchParams.get('token')
278
+
279
+ const shared = {
280
+ httpOnly: true,
281
+ maxAge: previewMaxAgeSeconds,
282
+ path: '/',
283
+ sameSite,
284
+ secure: sameSite === 'none',
285
+ }
286
+
287
+ if (token) {
288
+ /*
289
+ * `maxAge` rather than a session cookie. The doc above says the token "expires with
290
+ * their session", which is true of the JWT inside it and false of the cookie holding
291
+ * it: browsers restore sessions instead of ending them, so a session cookie is a
292
+ * permanent one with extra steps.
293
+ */
294
+ store.set(PREVIEW_TOKEN_COOKIE, token, shared)
295
+ }
296
+
297
+ /*
298
+ * The stamp `resolvePreview` reads. Set on every entry, so the window tracks the last
299
+ * time somebody actually asked to preview rather than the first.
300
+ */
301
+ store.set(PREVIEW_UNTIL_COOKIE, String(Date.now() + previewMaxAgeSeconds * 1000), shared)
302
+
303
+ await onAuthorised?.(store, { sameSite, secure: sameSite === 'none' })
304
+
305
+ /*
306
+ * An explicit route wins over the document's slug.
307
+ *
308
+ * The dashboard sends one for the screens that have no `pages` document behind them
309
+ * — a collection row's own page, `/projects/port-coogee`, is one route serving
310
+ * however many rows the client has. Without this there is no way to frame such a
311
+ * page in draft mode at all, so a client could never edit a project where it
312
+ * actually appears.
313
+ *
314
+ * Run through the same `safeReturnPath` as the exit route: it arrives in a query
315
+ * string, so it is not ours to trust even with the secret verified.
316
+ */
317
+ const path = searchParams.get('path')
318
+ const locale = canonicalLocale(searchParams.get('locale'))
319
+ const previewPath = (logical: string) => {
320
+ const target = new URL(
321
+ locale ? localePath(logical, locale) : logical,
322
+ 'https://preview.invalid',
323
+ )
324
+ const revision = searchParams.get('languageRevision')
325
+ if (locale && revision && /^\d+$/.test(revision))
326
+ target.searchParams.set('languageRevision', revision)
327
+ return target.pathname + target.search + target.hash
328
+ }
329
+ if (path) return redirect(previewPath(safeReturnPath(path)))
330
+
331
+ const slug = searchParams.get('slug') ?? ''
332
+ /*
333
+ * `return` even though `redirect` never returns: it is a destructured parameter, so
334
+ * TypeScript won't narrow the call to unreachable and would report the handler as
335
+ * missing a return. `never` is assignable to the declared type, so this is honest
336
+ * rather than a cast.
337
+ */
338
+ return redirect(previewPath(slug && slug !== 'home' ? `/${slug}` : '/'))
339
+ }
340
+
341
+ /**
342
+ * Only same-origin paths, so `?to=` cannot be turned into an open redirect.
343
+ *
344
+ * `//evil.com` and `/\evil.com` are both browser-protocol-relative, so a leading slash
345
+ * is not on its own enough.
346
+ */
347
+ const safeReturnPath = (value: null | string): string => {
348
+ if (!value || !value.startsWith('/')) return '/'
349
+ if (value.startsWith('//') || value.startsWith('/\\')) return '/'
350
+ return value
351
+ }
352
+
353
+ /**
354
+ * `GET /api/exit-preview` — leave draft mode.
355
+ *
356
+ * export const GET = createExitPreviewRoute({ cookies, draftMode, redirect })
357
+ *
358
+ * **Why this has to exist.** `createPreviewRoute` enables Next's draft mode and stashes
359
+ * the Payload token, and both are *session* cookies with no expiry. Nothing expires
360
+ * them and, until this route, nothing cleared them — so one click of Preview left that
361
+ * browser reading drafts on every page of the site until it was quit. The operator sees
362
+ * their unpublished edits on the live domain and reports, entirely reasonably, that the
363
+ * site changes before Publish is clicked. Visitors were never affected; the person
364
+ * checking was the only one who could see it.
365
+ *
366
+ * The pair with `createPreviewRoute` is the whole design, so the two live next to each
367
+ * other and the token is cleared with the same attributes it was set with.
368
+ *
369
+ * No secret, deliberately. Leaving draft mode is not a privileged act — the worst a
370
+ * stranger can do with this URL is take someone out of a mode they were not in.
371
+ *
372
+ * The site's own access cookie is left alone. A prospect site's private-preview gate is
373
+ * a different door, and slamming it on the way out would lock the operator off the site
374
+ * they were just editing.
375
+ */
376
+ export const createExitPreviewRoute = ({
377
+ cookies,
378
+ draftMode,
379
+ redirect,
380
+ sameSite = process.env.NODE_ENV === 'production' ? 'none' : 'lax',
381
+ }: {
382
+ cookies: () => Promise<{
383
+ set: (name: string, value: string, options?: Record<string, unknown>) => void
384
+ }>
385
+ draftMode: () => Promise<{ disable: () => void }>
386
+ redirect: (url: string) => never
387
+ /** Matched to `createPreviewRoute`'s, since this clears what that one set. */
388
+ sameSite?: 'lax' | 'none'
389
+ }) =>
390
+ async function GET(request: Request): Promise<Response> {
391
+ const draft = await draftMode()
392
+ draft.disable()
393
+
394
+ /*
395
+ * Expired rather than deleted. A cookie is cleared only by a `Set-Cookie` whose
396
+ * path matches the one it was stored under, and this handler lives at
397
+ * `/api/exit-preview` — `delete(name)` would emit no path and the browser would
398
+ * scope it to `/api`, leaving the real `/` cookie in place and the fix looking
399
+ * like it silently did nothing.
400
+ */
401
+ const store = await cookies()
402
+ const cleared = {
403
+ httpOnly: true,
404
+ maxAge: 0,
405
+ path: '/',
406
+ sameSite,
407
+ secure: sameSite === 'none',
408
+ }
409
+ store.set(PREVIEW_TOKEN_COOKIE, '', cleared)
410
+ // Cleared with the token, so leaving by the front door and lapsing agree on the state.
411
+ store.set(PREVIEW_UNTIL_COOKIE, '', cleared)
412
+
413
+ return redirect(safeReturnPath(new URL(request.url).searchParams.get('to')))
414
+ }