@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,91 @@
1
+ import { siteLocation } from './location'
2
+ import { matchRedirect, type RedirectMatch } from './match'
3
+ import { redirectsReplySchema, type RedirectRule } from './wire'
4
+
5
+ type Options = {
6
+ /** The rules baked into the build (`env.U90_REDIRECTS_SNAPSHOT`), as JSON or already parsed. Defaults to that variable. */
7
+ snapshot?: string | { rules: RedirectRule[]; generatedAt: string } | null
8
+ /** Defaults to `process.env.PLATFORM_URL`. Without one the snapshot is all there is. */
9
+ platformUrl?: string
10
+ /** Defaults to `process.env.PAYLOAD_API_KEY`. */
11
+ apiKey?: string
12
+ fetch?: typeof fetch
13
+ now?: () => number
14
+ /** How long rules stay fresh before the next request starts a refresh. Defaults to 60. */
15
+ refreshSeconds?: number
16
+ }
17
+
18
+ /**
19
+ * The redirect step of a site's `proxy.ts`: `return (await redirects(request)) ?? next()`.
20
+ * It runs before routing, so a moved address never renders. Rules start from the build's snapshot and
21
+ * refresh from the v1 read API with the site's key. A refresh never delays a request: the request that
22
+ * finds the rules stale is answered from the rules it has, and the next one sees the new ones. The last
23
+ * good rules survive a failed or malformed refresh, and the next attempt waits a full interval.
24
+ * Next's own paths (`/_next/`) and the API (`/api/`) are never redirected.
25
+ */
26
+ export function redirectsProxy(o: Options = {}): (request: Request) => Promise<Response | undefined> {
27
+ const platformUrl = o.platformUrl ?? process.env.PLATFORM_URL ?? ''
28
+ const apiKey = o.apiKey ?? process.env.PAYLOAD_API_KEY
29
+ const fetchImpl = o.fetch ?? globalThis.fetch
30
+ const now = o.now ?? Date.now
31
+ const intervalMs = (o.refreshSeconds ?? 60) * 1000
32
+ const snapshot = o.snapshot === undefined ? process.env.U90_REDIRECTS_SNAPSHOT : o.snapshot
33
+
34
+ let rules: readonly RedirectRule[] = []
35
+ if (snapshot) {
36
+ try {
37
+ const parsed = redirectsReplySchema.safeParse(typeof snapshot === 'string' ? JSON.parse(snapshot) : snapshot)
38
+ if (parsed.success) rules = parsed.data.rules
39
+ else console.warn('U90_REDIRECTS_SNAPSHOT is not a valid set of redirect rules, so the proxy starts with none until the platform answers.')
40
+ } catch {
41
+ console.warn('U90_REDIRECTS_SNAPSHOT is not readable JSON, so the proxy starts with no redirect rules until the platform answers.')
42
+ }
43
+ }
44
+
45
+ let refreshedAt = 0
46
+ let inFlight = false
47
+ async function refresh() {
48
+ inFlight = true
49
+ refreshedAt = now()
50
+ try {
51
+ const res = await fetchImpl(`${platformUrl.replace(/\/+$/, '')}/api/u90/v1/redirects`, { headers: { authorization: `Bearer ${apiKey ?? ''}` }, cache: 'no-store', signal: AbortSignal.timeout(5000) })
52
+ if (!res.ok) { console.warn(`The platform answered ${res.status} for the site's redirect rules, so the last good rules stay.`); return }
53
+ const parsed = redirectsReplySchema.safeParse(await res.json())
54
+ if (parsed.success) rules = parsed.data.rules
55
+ else console.warn('The platform\'s redirect rules did not parse, so the last good rules stay.')
56
+ } catch (err) {
57
+ console.warn(`The site's redirect rules could not be refreshed (${err instanceof Error ? err.message : String(err)}), so the last good rules stay.`)
58
+ } finally {
59
+ inFlight = false
60
+ }
61
+ }
62
+
63
+ /** Each warning once per rule: `unusable:<source>` for a destination no URL could be made of, `unsafe:<source>` for a refused Location. */
64
+ const warned = new Set<string>()
65
+ const warnOnce = (key: string, message: string) => {
66
+ if (warned.has(key)) return
67
+ warned.add(key)
68
+ console.warn(message)
69
+ }
70
+
71
+ // The `Location` is made safe by `siteLocation`: absolute, every run of `/` and `\` in a same-origin path collapsed, and a
72
+ // same-origin path under `/_next/` refused. A refused or unusable destination passes the request through rather than answer 500.
73
+ const answer = (m: RedirectMatch | null, requestUrl: URL): Response | undefined => {
74
+ if (!m) return undefined
75
+ if (m.status === 410) return new Response('Gone', { status: 410, headers: { 'content-type': 'text/plain; charset=utf-8' } })
76
+ const made = siteLocation(m.destination, requestUrl)
77
+ if ('refused' in made) {
78
+ if (made.refused === 'unusable') warnOnce(`unusable:${m.rule.source}`, `The redirect from ${m.rule.source} has a destination that is not a usable address, so requests for it pass through.`)
79
+ else warnOnce(`unsafe:${m.rule.source}`, `A request matched the redirect from ${m.rule.source} but its Location would be under /_next/ or leave the site, so it passed through. Requests like this are not warned about again for this rule.`)
80
+ return undefined
81
+ }
82
+ return new Response(null, { status: m.status, headers: { location: made.location.href } })
83
+ }
84
+
85
+ return async (request) => {
86
+ const url = new URL(request.url)
87
+ if (url.pathname.startsWith('/_next/') || url.pathname.startsWith('/api/')) return undefined
88
+ if (platformUrl && !inFlight && now() - refreshedAt >= intervalMs) void refresh()
89
+ return answer(matchRedirect(rules, url.pathname + url.search), url)
90
+ }
91
+ }
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod'
2
+
3
+ /** The most rules a reply may carry. */
4
+ export const MAX_REDIRECT_RULES = 2000
5
+
6
+ /** What a rule does when it matches. `410` means gone and carries no destination. */
7
+ export const redirectStatusSchema = z.union([z.literal(301), z.literal(302), z.literal(410)])
8
+ export const redirectMatchTypeSchema = z.enum(['exact', 'prefix', 'pattern'])
9
+
10
+ /** One rule as the platform serves it. `source` is already normalised by `normalisePath` when the platform stores it. */
11
+ export const redirectRuleSchema = z.object({
12
+ source: z.string().startsWith('/'),
13
+ /** A same-origin path, or an absolute URL for an off-site move. Empty for `410`. */
14
+ destination: z.string(),
15
+ status: redirectStatusSchema,
16
+ matchType: redirectMatchTypeSchema,
17
+ preserveQuery: z.boolean(),
18
+ })
19
+
20
+ /** What `GET /api/u90/v1/redirects` returns. */
21
+ export const redirectsReplySchema = z.object({
22
+ rules: z.array(redirectRuleSchema).max(MAX_REDIRECT_RULES),
23
+ /** When the platform generated it. Carried for diagnostics, never used for matching. */
24
+ generatedAt: z.string(),
25
+ })
26
+
27
+ export type RedirectStatus = z.infer<typeof redirectStatusSchema>
28
+ export type RedirectMatchType = z.infer<typeof redirectMatchTypeSchema>
29
+ export type RedirectRule = z.infer<typeof redirectRuleSchema>
30
+ export type RedirectsReply = z.infer<typeof redirectsReplySchema>
@@ -0,0 +1,70 @@
1
+ import type { OpeningHoursDay, OpeningHoursRow, SiteReply } from '../site/wire'
2
+
3
+ /** A value trimmed, or null when it is blank or absent. */
4
+ const filled = (value: null | string | undefined): null | string => {
5
+ const trimmed = (value ?? '').trim()
6
+ return trimmed === '' ? null : trimmed
7
+ }
8
+
9
+ type OpeningHoursSpec = {
10
+ '@type': 'OpeningHoursSpecification'
11
+ closes: string
12
+ dayOfWeek: OpeningHoursDay[]
13
+ opens: string
14
+ }
15
+
16
+ /**
17
+ * `organisation.openingHours`, as schema.org `OpeningHoursSpecification` entries.
18
+ *
19
+ * A closed day, or a day with no `opens`/`closes` pair, has nothing schema.org can use and is
20
+ * dropped rather than emitted with a null time: Google reports a spec entry with a missing time as
21
+ * an error, which is worse than saying nothing about that day.
22
+ *
23
+ * Days sharing an identical `{opens, closes}` collapse into one entry listing every `dayOfWeek`
24
+ * that applies, which schema.org allows.
25
+ */
26
+ const openingHoursSpecification = (rows: readonly OpeningHoursRow[]): OpeningHoursSpec[] => {
27
+ const groups = new Map<string, OpeningHoursSpec>()
28
+ for (const row of rows) {
29
+ if (row.closed || row.opens === null || row.closes === null) continue
30
+ const key = `${row.opens}|${row.closes}`
31
+ const existing = groups.get(key)
32
+ if (existing) existing.dayOfWeek.push(row.day)
33
+ else groups.set(key, { '@type': 'OpeningHoursSpecification', closes: row.closes, dayOfWeek: [row.day], opens: row.opens })
34
+ }
35
+ return [...groups.values()]
36
+ }
37
+
38
+ /**
39
+ * The organisation, as schema.org JSON-LD, or null.
40
+ *
41
+ * The name is the organisation's `legalName`, else the site name. Without either, or without an
42
+ * organisation, there is none: a business panel with no name is malformed structured data, and
43
+ * Google reports it as an error against the site.
44
+ */
45
+ export const organisationJsonLd = ({ origin, site }: { origin: string; site: SiteReply | null }): Record<string, unknown> | null => {
46
+ const organisation = site?.organisation
47
+ const name = filled(organisation?.legalName) ?? filled(site?.siteName)
48
+ if (!organisation || !name) return null
49
+
50
+ const openingHoursSpec = openingHoursSpecification(organisation.openingHours)
51
+
52
+ return {
53
+ '@context': 'https://schema.org',
54
+ '@type': 'Organization',
55
+ name,
56
+ url: origin.replace(/\/$/, ''),
57
+ ...(organisation.logo?.url ? { logo: organisation.logo.url.startsWith('/') && !organisation.logo.url.startsWith('//') ? new URL(organisation.logo.url, origin).toString() : organisation.logo.url } : {}),
58
+ ...(filled(organisation.phone) ? { telephone: organisation.phone } : {}),
59
+ ...(filled(organisation.email) ? { email: organisation.email } : {}),
60
+ ...(filled(organisation.address) ? { address: organisation.address } : {}),
61
+ // Structured hours win when there are any. The free-text field is the fallback for a tenant
62
+ // who has not used the structured editor, emitted as a string.
63
+ ...(openingHoursSpec.length > 0
64
+ ? { openingHoursSpecification: openingHoursSpec }
65
+ : filled(organisation.legacyOpeningHours)
66
+ ? { openingHours: organisation.legacyOpeningHours }
67
+ : {}),
68
+ ...(organisation.sameAs.length > 0 ? { sameAs: organisation.sameAs } : {}),
69
+ }
70
+ }
@@ -0,0 +1,127 @@
1
+ import type { Metadata } from 'next'
2
+ import type { LanguageAlternates } from '../languages/routing'
3
+ import type { ReplySeo, SiteReply } from '../site/wire'
4
+ import { applyTitleTemplate } from './title'
5
+
6
+ /** A value trimmed, or null when it is blank or absent: an empty field falls back rather than overriding. */
7
+ const filled = (value: string | null | undefined): string | null => {
8
+ const trimmed = (value ?? '').trim()
9
+ return trimmed === '' ? null : trimmed
10
+ }
11
+
12
+ /** A page's SEO as v1's reply gives it (`ReplySeo`), with its `ogImage` media id resolved to the media's URL. */
13
+ export type PageSeoInput = Omit<ReplySeo, 'ogImage'> & { ogImage: { url: string; alt?: string } | null }
14
+
15
+ export type MetadataInput = {
16
+ /** The platform's site-wide SEO settings; null for a site with no platform, or when `/site` could not be read. */
17
+ site: SiteReply | null
18
+ /** The site code's own `seo` defaults, used field by field where the platform has set nothing: its template, share image and organisation name. */
19
+ manifest: { titleTemplate?: string; defaultImage?: string; organisation?: { name?: string } } | null
20
+ page: PageSeoInput | null
21
+ /** The page's own title: its stored title, else its key or slug. The template is applied to it. */
22
+ pageTitle: string
23
+ /** The site's origin. Without one, and with no canonical of the page's own, no canonical is set. */
24
+ origin?: string
25
+ /** The path this page answers at, e.g. `/about-us` or `/`. */
26
+ path: string
27
+ /** The page's language, when the site has languages: `path` is then that language's own address (`/fr/about`). */
28
+ language?: LanguageAlternates
29
+ }
30
+
31
+ /**
32
+ * A path made absolute against the site's URL, as v1's sitemap lists it and its canonicals name it:
33
+ * the home page is `https://example.com/`, with its slash.
34
+ */
35
+ export const siteUrlFor = (origin: string, path: string): string => new URL(path, origin).toString()
36
+
37
+ /**
38
+ * v1's indexing rule. A site whose platform turned indexing off is neither indexed nor followed.
39
+ * A page that asks not to be listed is still followed. A site the platform said nothing about
40
+ * (no platform, or a `/site` read that failed) sets no robots at all: a failed read must never
41
+ * take a site out of search.
42
+ */
43
+ const v1Robots = (site: SiteReply | null, page: PageSeoInput | null): Metadata['robots'] =>
44
+ site?.allowIndexing === false ? { follow: false, index: false } : page?.noindex === true ? { follow: true, index: false } : undefined
45
+
46
+ /**
47
+ * Next metadata for a v1 page: the page's SEO over the platform's site-wide SEO over the manifest's
48
+ * defaults, with v1's indexing rule. The page's SEO title is the whole title, as the platform's
49
+ * SEO screen shows it; without one, the platform's template, else the manifest's, is applied to
50
+ * the page's own title, with the platform's site name, else the manifest's organisation name, as
51
+ * `%site%`. The platform answers null for a template its tenant never chose, so a site keeps its
52
+ * manifest's until the tenant sets one. Every string counts as set only when it holds more than
53
+ * whitespace, so a person who clears a field gets the fallback rather than an empty value, and a
54
+ * key with no value is left out.
55
+ */
56
+ /**
57
+ * A language's Open Graph locale, `language_TERRITORY` as Open Graph requires: a bare language takes its likeliest
58
+ * territory (`fr` is `fr_FR`), and an id the runtime cannot place keeps its own spelling with `_` for `-`.
59
+ */
60
+ export function ogLocale(id: string): string {
61
+ try {
62
+ const { language, region } = new Intl.Locale(id).maximize()
63
+ if (region) return `${language}_${region}`
64
+ } catch {
65
+ // not a language tag the runtime knows
66
+ }
67
+ return id.replace(/-/g, '_')
68
+ }
69
+
70
+ export function metadataFor(i: MetadataInput): Metadata {
71
+ const { site, manifest, page } = i
72
+ const siteName = filled(site?.siteName) ?? filled(manifest?.organisation?.name)
73
+ const title = filled(page?.title) ?? filled(applyTitleTemplate({ pageTitle: i.pageTitle, siteName, template: filled(site?.titleTemplate) ?? filled(manifest?.titleTemplate) }))
74
+ const description = filled(page?.description) ?? filled(site?.description)
75
+ // Social fields fall back to the resolved title and description, never the raw template.
76
+ const ogTitle = filled(page?.ogTitle) ?? title
77
+ const ogDescription = filled(page?.ogDescription) ?? description
78
+ const siteImage = site?.ogImage?.url ? { url: site.ogImage.url, alt: site.ogImage.alt } : null
79
+ const defaultImage = filled(manifest?.defaultImage)
80
+ const image = page?.ogImage ?? siteImage ?? (defaultImage ? { url: defaultImage } : null)
81
+ /** A share image given as a path (kit 1.5 media) is absolute against the site's own origin; a URL, protocol-relative included, is left alone. */
82
+ const imageUrl = image?.url && image.url.startsWith('/') && !image.url.startsWith('//') && i.origin !== undefined ? siteUrlFor(i.origin, image.url) : image?.url
83
+ const google = filled(site?.googleVerification)
84
+ const bing = filled(site?.bingVerification)
85
+ const verification: Record<string, string> = {}
86
+ if (google) verification['google-site-verification'] = google
87
+ if (bing) verification['msvalidate.01'] = bing
88
+ const handle = filled(site?.twitterHandle)
89
+ // R9: a page in a language other than the default names itself, never a canonical stored on the shared base document.
90
+ const own = i.language !== undefined && !i.language.isDefault
91
+ const locale = own ? ogLocale(i.language!.id) : filled(site?.locale)
92
+ const self = i.origin !== undefined ? siteUrlFor(i.origin, i.path) : null
93
+ const stored = own ? null : filled(page?.canonical)
94
+ const canonical = stored ?? self
95
+ // hreflang only on a page that is its own canonical (Google ignores it elsewhere), and only on a variant offered to search (ruling R2).
96
+ const namesItself = stored === null || (self !== null && i.origin !== undefined && siteUrlFor(i.origin, stored) === self)
97
+ const listed = i.language !== undefined && i.origin !== undefined && i.language.indexable && namesItself
98
+ // One language alone has no alternate to name, as its sitemap entry names none.
99
+ const languages = listed && i.language!.alternates.length > 1
100
+ ? { ...Object.fromEntries(i.language!.alternates.map((a) => [a.id, siteUrlFor(i.origin!, a.path)])), 'x-default': siteUrlFor(i.origin!, i.language!.xDefault) }
101
+ : null
102
+ const alternateLocale = listed ? i.language!.alternates.filter((a) => a.id !== i.language!.id).map((a) => ogLocale(a.id)) : []
103
+ const robots = v1Robots(site, page) ?? (i.language && !i.language.indexable ? { follow: true, index: false } : undefined)
104
+ return {
105
+ ...(canonical || languages ? { alternates: { ...(canonical ? { canonical } : {}), ...(languages ? { languages } : {}) } } : {}),
106
+ ...(description ? { description } : {}),
107
+ openGraph: {
108
+ ...(ogDescription ? { description: ogDescription } : {}),
109
+ ...(imageUrl ? { images: [{ ...(image?.alt !== undefined ? { alt: image.alt } : {}), url: imageUrl }] } : {}),
110
+ ...(alternateLocale.length ? { alternateLocale } : {}),
111
+ ...(locale ? { locale } : {}),
112
+ ...(siteName ? { siteName } : {}),
113
+ ...(ogTitle ? { title: ogTitle } : {}),
114
+ type: 'website',
115
+ },
116
+ ...(Object.keys(verification).length > 0 ? { other: verification } : {}),
117
+ ...(robots ? { robots } : {}),
118
+ ...(title ? { title } : {}),
119
+ twitter: {
120
+ card: site?.twitterCard ?? 'summary_large_image',
121
+ ...(ogDescription ? { description: ogDescription } : {}),
122
+ ...(imageUrl ? { images: [imageUrl] } : {}),
123
+ ...(handle ? { site: handle.startsWith('@') ? handle : `@${handle}` } : {}),
124
+ ...(ogTitle ? { title: ogTitle } : {}),
125
+ },
126
+ }
127
+ }
@@ -0,0 +1,14 @@
1
+ import type { MetadataRoute } from 'next'
2
+ import type { SiteReply } from '../site/wire'
3
+ import { siteUrlFor } from './metadata'
4
+
5
+ /**
6
+ * `robots.txt`: every crawler is allowed, and the sitemap named, unless the platform's
7
+ * `allowIndexing` is off, when everything is disallowed and no sitemap is named. A `/site` read that
8
+ * failed (`site` null) allows crawling, so a platform hiccup never takes a site out of search. With
9
+ * no origin there is no absolute sitemap URL to name.
10
+ */
11
+ export const robotsFor = ({ origin, site }: { origin: string | undefined; site: SiteReply | null }): MetadataRoute.Robots =>
12
+ site?.allowIndexing === false
13
+ ? { rules: { userAgent: '*', disallow: '/' } }
14
+ : { rules: { userAgent: '*', allow: '/' }, ...(origin ? { sitemap: siteUrlFor(origin, '/sitemap.xml') } : {}) }
@@ -0,0 +1,22 @@
1
+ import type { MetadataRoute } from 'next'
2
+ import { addressIn, type SiteLanguages } from '../languages/routing'
3
+ import type { SitemapReply } from '../site/wire'
4
+ import { siteUrlFor } from './metadata'
5
+
6
+ /**
7
+ * The platform's sitemap entries as Next's `sitemap.xml` takes them: absolute against the site's origin, or as stored without
8
+ * one. With languages, each entry is listed at its address in each language the platform lists it in (every active language
9
+ * when it lists none), in the settings' order. Each url of an entry listed in more than one language names the others and
10
+ * x-default (plan 4 Task 4 minor).
11
+ */
12
+ export const sitemapFor = (origin: string | undefined, entries: SitemapReply['entries'], languages?: SiteLanguages | null): MetadataRoute.Sitemap =>
13
+ entries.flatMap((e) => {
14
+ const urlOf = (path: string) => (origin ? siteUrlFor(origin, path) : path)
15
+ const lastModified = e.lastModified ? { lastModified: e.lastModified } : {}
16
+ if (!languages) return [{ url: urlOf(e.path), ...lastModified }]
17
+ // The platform lists an entry's languages (ruling R2); a platform from before says nothing, and every active language is listed.
18
+ const listed = languages.active.filter((language) => e.languages === undefined || e.languages.includes(language.id))
19
+ const urls = Object.fromEntries(listed.map((language) => [language.id, urlOf(addressIn(languages, language.id, e.path))]))
20
+ const alternates = listed.length > 1 ? { alternates: { languages: { ...urls, 'x-default': urlOf(e.path) } } } : {}
21
+ return listed.map((language) => ({ url: urls[language.id]!, ...lastModified, ...alternates }))
22
+ })
@@ -0,0 +1,22 @@
1
+ /** A value that holds more than whitespace, trimmed; anything else is unset. */
2
+ const filled = (s: string | null): string | null => (s && s.trim() !== '' ? s.trim() : null)
3
+ /** The separators a template puts between the page and the site: `—`, `|`, `·`, `:` and `-`. */
4
+ const SEPARATOR = '[—|·:-]'
5
+
6
+ /**
7
+ * A page's title through a template. `%page%` and `%s` are the page title, `%site%` the site name.
8
+ * With no site name, `%site%` goes and so does the separator beside it. A template with no page
9
+ * token is used as written. Substitution is one pass, so a title holding `$&` or a token is
10
+ * inserted as written.
11
+ */
12
+ export const applyTitleTemplate = ({ pageTitle, siteName, template }: { pageTitle: string | null; siteName: string | null; template: string | null }): string => {
13
+ const page = filled(pageTitle)
14
+ const site = filled(siteName)
15
+ const shape = filled(template)
16
+ if (!shape) return page ?? site ?? ''
17
+ if (!page) return site ?? ''
18
+ const withSite = site
19
+ ? shape
20
+ : shape.replace(new RegExp(`\\s*${SEPARATOR}\\s*%site%`), '').replace(new RegExp(`%site%\\s*${SEPARATOR}\\s*`), '').replace('%site%', '')
21
+ return withSite.replace(/%page%|%site%|%s/g, (token) => (token === '%site%' ? (site ?? '') : page)).trim()
22
+ }
@@ -0,0 +1,73 @@
1
+ import { cacheLife, cacheTag, revalidateTag } from 'next/cache'
2
+ import { cookies, draftMode } from 'next/headers'
3
+ import { notFound, redirect } from 'next/navigation'
4
+ import { connection } from 'next/server'
5
+ import { cache } from 'react'
6
+ import { PREVIEW_BINDING_COOKIE } from '../test-preview/binding'
7
+ import { isTestPreviewToken } from '../test-preview/protocol.mjs'
8
+
9
+ export type NextAdapters = {
10
+ cacheTag(...tags: string[]): void; cacheLife(profile: string): void
11
+ /** Lightweight draft-mode check, called inside a cache scope; must not read cookies/headers. */
12
+ draftEnabled?(): Promise<boolean>
13
+ draft(): Promise<{ enabled: boolean; token: string | null }>
14
+ /** Entry-only cookie access, after enabling draft; never called by ordinary reads. */
15
+ draftBinding?(): Promise<{ bypass: string | null; binding: string | null }>
16
+ enableDraftBinding?(binding: string, maxAge: number): Promise<void>
17
+ enableDraft(token: string): Promise<void>; disableDraft(): Promise<void>
18
+ revalidateTag(tag: string): Promise<void>; notFound(): never; redirect(url: string): never
19
+ /** Cache Components: reading this inside `routes.sitemap` forces per-request evaluation instead of build-time caching (R6). */
20
+ connection(): Promise<void>
21
+ /** `fn` called at most once per request, its promise shared by every caller in that request: React's `cache`, which Next scopes to one request across `generateMetadata`, layouts and the page. */
22
+ perRequest<T>(fn: () => Promise<T>): () => Promise<T>
23
+ }
24
+ export const PREVIEW_COOKIE = 'u90-preview-token'
25
+ /**
26
+ * `cookies()` is a request-time API: calling it makes the render that called it dynamic, so reading
27
+ * the preview cookie on every read made every production render dynamic. `draftMode().isEnabled`
28
+ * is not (Next lets a `'use cache'` scope read it), so it is asked first and the cookie is read
29
+ * only in draft. The two request APIs are parameters so a test can see which were called.
30
+ */
31
+ export async function readDraft(
32
+ mode: () => Promise<{ isEnabled: boolean }>,
33
+ jar: () => Promise<{ get(name: string): { value: string } | undefined }>,
34
+ ): Promise<{ enabled: boolean; token: string | null }> {
35
+ if (!(await mode()).isEnabled) return { enabled: false, token: null }
36
+ return { enabled: true, token: (await jar()).get(PREVIEW_COOKIE)?.value ?? null }
37
+ }
38
+ export const nextAdapters: NextAdapters = {
39
+ cacheTag: (...tags) => cacheTag(...tags),
40
+ cacheLife: (profile) => cacheLife(profile as never),
41
+ draftEnabled: async () => (await draftMode()).isEnabled,
42
+ draft: () => readDraft(draftMode, cookies),
43
+ enableDraft: (token) => writeDraft(draftMode, cookies, token),
44
+ draftBinding: async () => { const jar = await cookies(); return { bypass: jar.get('__prerender_bypass')?.value ?? null, binding: jar.get(PREVIEW_BINDING_COOKIE)?.value ?? null } },
45
+ enableDraftBinding: async (binding, maxAge) => { (await cookies()).set(PREVIEW_BINDING_COOKIE, binding, { httpOnly: true, sameSite: 'none', secure: true, path: '/', maxAge }) },
46
+ disableDraft: () => clearDraft(draftMode, cookies),
47
+ revalidateTag: async (tag) => { revalidateTag(tag, { expire: 0 }) },
48
+ notFound: () => notFound(), redirect: (url) => redirect(url),
49
+ connection: () => connection(),
50
+ perRequest: (fn) => cache(fn),
51
+ }
52
+ /** Public-cookie API injection keeps native cookie retention and cleanup testable without Next internals. */
53
+ type DraftCookieOptions = { httpOnly: true; sameSite: 'none'; secure: true; path: '/'; maxAge?: number }
54
+ export async function writeDraft(
55
+ mode: () => Promise<{ enable(): void }>,
56
+ jar: () => Promise<{ set(name: string, value: string, options: DraftCookieOptions): void }>,
57
+ token: string,
58
+ ): Promise<void> {
59
+ (await mode()).enable()
60
+ const options: DraftCookieOptions = { httpOnly: true, sameSite: 'none', secure: true, path: '/', ...(isTestPreviewToken(token) ? {} : { maxAge: 12 * 3600 }) }
61
+ // A test token stays as an expired-session tombstone until explicit exit/re-entry or browser-session end.
62
+ const store = await jar()
63
+ store.set(PREVIEW_COOKIE, token, options)
64
+ }
65
+ export async function clearDraft(
66
+ mode: () => Promise<{ disable(): void }>,
67
+ jar: () => Promise<{ delete(name: string): void }>,
68
+ ): Promise<void> {
69
+ (await mode()).disable()
70
+ const store = await jar()
71
+ store.delete(PREVIEW_COOKIE)
72
+ store.delete(PREVIEW_BINDING_COOKIE)
73
+ }
@@ -0,0 +1,96 @@
1
+ import { isTestPreviewToken } from '../test-preview/protocol.mjs'
2
+ import { createTestPreviewRuntime, requestPreviewToken, TestPreviewRefused, type TestPreviewOptions } from '../test-preview/runtime'
3
+ import { createSiteFeatures, type SiteFeaturesOptions } from '../features/server/gateway'
4
+ import { failure } from '../features/server/http'
5
+ import { manifestFrom, manifestHash, type Site } from '../site'
6
+ import { nextAdapters, type NextAdapters } from './adapters'
7
+ import { createHandlers } from './handlers'
8
+ import { createRead, type Env } from './read'
9
+ import { createRender } from './render'
10
+ import { createRoutes } from './routes'
11
+
12
+ export type U90Options = {
13
+ testPreview?: Pick<TestPreviewOptions, 'now' | 'trustTimeoutMs' | 'buildId'>
14
+ features?: Pick<SiteFeaturesOptions, 'verifyVisitor' | 'resolveClientIdentity'>
15
+ fetch?: typeof fetch
16
+ env?: Env
17
+ adapters?: Partial<NextAdapters>
18
+ }
19
+ export function createU90(site: Site, options: U90Options = {}) {
20
+ const env: Env = options.env ?? {
21
+ PLATFORM_URL: process.env.PLATFORM_URL,
22
+ PAYLOAD_API_KEY: process.env.PAYLOAD_API_KEY,
23
+ REVALIDATE_SECRET: process.env.REVALIDATE_SECRET,
24
+ SITE_URL: process.env.SITE_URL,
25
+ VERCEL_DEPLOYMENT_ID: process.env.VERCEL_DEPLOYMENT_ID,
26
+ VERCEL_URL: process.env.VERCEL_URL,
27
+ U90_TEST_PREVIEW_BUILD_ID: process.env.U90_TEST_PREVIEW_BUILD_ID,
28
+ }
29
+ const adapters = {
30
+ ...nextAdapters, ...options.adapters,
31
+ // A custom draft adapter must explicitly provide the lightweight check to support tests.
32
+ draftEnabled: options.adapters?.draftEnabled ?? (options.adapters?.draft ? undefined : nextAdapters.draftEnabled),
33
+ }
34
+ const runtime = createTestPreviewRuntime({ env, fetch: options.fetch, ...options.testPreview })
35
+ const testPreview = {
36
+ resolve: async (token: string | null) => {
37
+ if (!adapters.draftEnabled && isTestPreviewToken(token)) throw new TestPreviewRefused()
38
+ return runtime.resolve(token)
39
+ },
40
+ }
41
+ const manifest = manifestFrom(site) // throws SiteDeclarationError at module evaluation → next build fails
42
+ const runtimeRead = createRead({
43
+ site,
44
+ fetch: options.fetch ?? fetch,
45
+ env,
46
+ adapters,
47
+ running: manifestHash(manifest),
48
+ testPreview,
49
+ })
50
+ // The published-only compatibility reader is private to legacy html root attributes.
51
+ const { legacyHtmlLanguages: _privateReader, ...read } = runtimeRead
52
+ const r = createRender(site)
53
+ const legacy = createHandlers(manifest, env, adapters, options.fetch ?? fetch, testPreview, options.testPreview?.now, options.testPreview?.buildId)
54
+ const features =
55
+ env.PLATFORM_URL && env.PAYLOAD_API_KEY && env.SITE_URL
56
+ ? createSiteFeatures({
57
+ env: {
58
+ PLATFORM_URL: env.PLATFORM_URL,
59
+ PAYLOAD_API_KEY: env.PAYLOAD_API_KEY,
60
+ SITE_URL: env.SITE_URL,
61
+ },
62
+ fetch: options.fetch,
63
+ ...options.features,
64
+ })
65
+ : undefined
66
+ const reserved = (request: Request) => {
67
+ try {
68
+ return /^\/api\/u90\/features(?:\/|$)/.test(decodeURIComponent(new URL(request.url).pathname))
69
+ } catch {
70
+ return true
71
+ }
72
+ }
73
+ const featureRequest = async (request: Request) => {
74
+ try {
75
+ const context = await testPreview.resolve(requestPreviewToken(request))
76
+ const gateway = context ? createSiteFeatures({ env: { PLATFORM_URL: context.platformOrigin, PAYLOAD_API_KEY: context.token, SITE_URL: context.siteOrigin }, fetch: options.fetch, ...options.features, visitorCookieName: context.visitorCookieName, testPreview: true }) : features
77
+ return gateway ? gateway[request.method === 'POST' ? 'POST' : 'GET'](request) : failure('unavailable')
78
+ } catch { return failure('forbidden') }
79
+ }
80
+ const handlers = {
81
+ GET: (request: Request, ctx: Parameters<typeof legacy.GET>[1]) => reserved(request) ? featureRequest(request) : legacy.GET(request, ctx),
82
+ POST: (request: Request, ctx: Parameters<typeof legacy.POST>[1]) => reserved(request) ? featureRequest(request) : legacy.POST(request, ctx),
83
+ }
84
+ return {
85
+ read,
86
+ /** Verified server-only context; never serialize its token into client props. */
87
+ testPreview: async () => testPreview.resolve((await adapters.draft()).token),
88
+ routes: createRoutes(site, runtimeRead, r, adapters, env.SITE_URL),
89
+ handlers,
90
+ render: r.render,
91
+ Blocks: r.Blocks,
92
+ Layout: r.Layout,
93
+ manifest,
94
+ }
95
+ }
96
+ export type U90 = ReturnType<typeof createU90>