@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,327 @@
1
+ import type { OpeningHoursDay, OpeningHoursRow, PageSeo, SiteSeo } from './content'
2
+
3
+ /**
4
+ * Titles, metadata and JSON-LD: the one implementation, shared by this kit, v1 (`../seo/*`, which
5
+ * wraps it) and the platform's SEO screen, so each resolves a page the same way. It lives here
6
+ * rather than in `../seo` because this folder is packed on its own as the kit every launched site
7
+ * vendors (`stageLegacyKit`), so nothing here may import from outside it.
8
+ *
9
+ * Pure and free of Next at runtime. Every rule this kit's `metadataFor` applies is one line in
10
+ * `verify:seo-metadata`.
11
+ *
12
+ * **Empty is not an override.** A string that is blank or only whitespace falls back, so a client
13
+ * who clears a field gets the site default rather than an empty title.
14
+ */
15
+
16
+ /**
17
+ * A page's metadata in the subset of Next's `Metadata` this kit sets. It is structurally
18
+ * assignable to `Metadata` (v1's `metadataFor`, `../seo/metadata.ts`, returns one as the other, so
19
+ * the compiler checks it). A legacy site writes
20
+ * `export const generateMetadata = async (): Promise<Metadata> => metadataFor({...})` and the
21
+ * types line up without a cast, whatever version of Next it runs.
22
+ */
23
+ export type SeoMetadata = {
24
+ alternates?: { canonical?: string; languages?: Record<string, string> }
25
+ description?: string
26
+ openGraph?: {
27
+ description?: string
28
+ images?: { alt?: string; url: string }[]
29
+ locale?: string
30
+ siteName?: string
31
+ title?: string
32
+ type?: 'website'
33
+ }
34
+ other?: Record<string, string>
35
+ robots?: { follow: boolean; index: boolean }
36
+ title?: string
37
+ twitter?: {
38
+ card?: 'summary' | 'summary_large_image'
39
+ description?: string
40
+ images?: string[]
41
+ site?: string
42
+ title?: string
43
+ }
44
+ }
45
+
46
+ /** A value trimmed, or null when it is blank: an empty field falls back rather than overriding. */
47
+ const filled = (value: null | string | undefined): null | string => {
48
+ const trimmed = (value ?? '').trim()
49
+ return trimmed === '' ? null : trimmed
50
+ }
51
+
52
+ /** The separators title templates use between the page and the site name. */
53
+ const SEPARATOR_CLASS = '[—|·:-]'
54
+
55
+ /**
56
+ * The page title a site renders: the template with `%page%`/`%site%` (the platform's spelling) or
57
+ * `%s` (the manifest's) replaced by the page's title and the site name, else the page title alone.
58
+ *
59
+ * A template with no page token in it is honoured rather than corrected: the SEO screen flags it,
60
+ * because a person who typed it may have meant it. A blank template means no template. With no
61
+ * page title the site name stands alone. With no site name, `%site%` goes together with the
62
+ * separator beside it, so `%page% — %site%` gives `About Us`, not `About Us —`.
63
+ */
64
+ export const applyTitleTemplate = ({
65
+ pageTitle,
66
+ siteName,
67
+ template,
68
+ }: {
69
+ pageTitle: null | string
70
+ siteName: null | string
71
+ template: null | string
72
+ }): string => {
73
+ const page = filled(pageTitle)
74
+ const site = filled(siteName)
75
+ const shape = filled(template)
76
+
77
+ if (!shape) return page ?? site ?? ''
78
+ if (!page) return site ?? ''
79
+
80
+ const withSite = site
81
+ ? shape
82
+ : shape
83
+ .replace(new RegExp(`\\s*${SEPARATOR_CLASS}\\s*%site%`), '')
84
+ .replace(new RegExp(`%site%\\s*${SEPARATOR_CLASS}\\s*`), '')
85
+ .replace('%site%', '')
86
+
87
+ // One pass, so a token inside the page title or the site name is not substituted again, and a
88
+ // function replacer, so a `$&` in either is inserted as written.
89
+ return withSite.replace(/%page%|%site%|%s/g, (token) => (token === '%site%' ? (site ?? '') : page)).trim()
90
+ }
91
+
92
+ /** Join an origin and a path without doubling or dropping the slash between them. */
93
+ export const joinUrl = (origin: string, path: string): string => {
94
+ const base = origin.replace(/\/$/, '')
95
+ if (path === '' || path === '/') return base || '/'
96
+ return `${base}/${path.replace(/^\//, '')}`
97
+ }
98
+
99
+ /** A page's SEO with its share image resolved to a URL: what the merge reads. v1's `PageSeoInput` is this. */
100
+ export type SeoPageInput = {
101
+ title: null | string
102
+ description: null | string
103
+ ogTitle: null | string
104
+ ogDescription: null | string
105
+ ogImage: { url: string; alt?: string } | null
106
+ canonical: null | string
107
+ noindex: boolean
108
+ }
109
+
110
+ export type SeoMergeInput = {
111
+ /** The platform's site-wide SEO settings; null for a site with no platform, or when they could not be read. */
112
+ site: null | SiteSeo
113
+ /** The site code's own `seo` defaults, used field by field where the platform has set nothing. Null for this kit's sites. */
114
+ manifest: { titleTemplate?: string; defaultImage?: string; organisation?: { name?: string } } | null
115
+ page: SeoPageInput | null
116
+ /** The page's own title. The template is applied to it. */
117
+ pageTitle: string
118
+ /** The site's origin. Without one, and with no canonical of the page's own, no canonical is set. */
119
+ origin?: string
120
+ /** The path this page answers at, e.g. `/about-us` or `/`. */
121
+ path: string
122
+ /** How `origin` and `path` make the canonical URL, so it matches the caller's sitemap. `joinUrl` (no slash after the home) when absent. */
123
+ urlFor?: (origin: string, path: string) => string
124
+ }
125
+
126
+ /**
127
+ * The merge, and the only place precedence is decided: the page's SEO over the platform's site-wide
128
+ * SEO over the manifest's defaults. `robots` is left to the caller's rule: v1's `metadataFor`
129
+ * (`../seo/metadata.ts`) and this kit's `metadataFor` below each have their own.
130
+ *
131
+ * The page's SEO title is the whole title, as the platform's SEO screen shows it. Without one, the
132
+ * template (the platform's, else the manifest's) is applied to the page's own title, and `%site%`
133
+ * is the platform's site name, else the manifest's organisation name. Each falls back on its own,
134
+ * so a platform template with no platform site name still names the site.
135
+ *
136
+ * Every string counts only when it holds more than whitespace, so a person who clears a field gets
137
+ * the fallback rather than an empty title.
138
+ */
139
+ export const seoMetadataFor = (i: SeoMergeInput, robots: SeoMetadata['robots']): SeoMetadata => {
140
+ const { site, manifest, page } = i
141
+ const siteName = filled(site?.siteName) ?? filled(manifest?.organisation?.name)
142
+ const title =
143
+ filled(page?.title) ??
144
+ filled(applyTitleTemplate({ pageTitle: i.pageTitle, siteName, template: filled(site?.titleTemplate) ?? filled(manifest?.titleTemplate) }))
145
+
146
+ const description = filled(page?.description) ?? filled(site?.description)
147
+
148
+ // Social fields fall back to the RESOLVED title and description, never the raw template.
149
+ const ogTitle = filled(page?.ogTitle) ?? title
150
+ const ogDescription = filled(page?.ogDescription) ?? description
151
+ const siteImage = site?.ogImage?.url ? { url: site.ogImage.url, alt: site.ogImage.alt } : null
152
+ const defaultImage = filled(manifest?.defaultImage)
153
+ const image = page?.ogImage ?? siteImage ?? (defaultImage ? { url: defaultImage } : null)
154
+
155
+ const verification: Record<string, string> = {}
156
+ const google = filled(site?.googleVerification)
157
+ const bing = filled(site?.bingVerification)
158
+ if (google) verification['google-site-verification'] = google
159
+ if (bing) verification['msvalidate.01'] = bing
160
+
161
+ const handle = filled(site?.twitterHandle)
162
+ const locale = filled(site?.locale)
163
+ const canonical = filled(page?.canonical) ?? (i.origin !== undefined ? (i.urlFor ?? joinUrl)(i.origin, i.path) : null)
164
+
165
+ return {
166
+ ...(canonical ? { alternates: { canonical } } : {}),
167
+ ...(description ? { description } : {}),
168
+ openGraph: {
169
+ ...(ogDescription ? { description: ogDescription } : {}),
170
+ ...(image?.url ? { images: [{ ...(image.alt !== undefined ? { alt: image.alt } : {}), url: image.url }] } : {}),
171
+ ...(locale ? { locale } : {}),
172
+ ...(siteName ? { siteName } : {}),
173
+ ...(ogTitle ? { title: ogTitle } : {}),
174
+ type: 'website',
175
+ },
176
+ ...(Object.keys(verification).length > 0 ? { other: verification } : {}),
177
+ ...(robots ? { robots } : {}),
178
+ ...(title ? { title } : {}),
179
+ twitter: {
180
+ card: site?.twitterCard ?? 'summary_large_image',
181
+ ...(ogDescription ? { description: ogDescription } : {}),
182
+ ...(image?.url ? { images: [image.url] } : {}),
183
+ ...(handle ? { site: handle.startsWith('@') ? handle : `@${handle}` } : {}),
184
+ ...(ogTitle ? { title: ogTitle } : {}),
185
+ },
186
+ }
187
+ }
188
+
189
+ /**
190
+ * This kit's `metadataFor`: the merge with no manifest defaults, and with this kit's own indexing
191
+ * rule. A page is indexed only while the platform says indexing is on and the page does not say
192
+ * noindex, so a null site (a failed read) is not indexed. `follow` tracks `index`.
193
+ */
194
+ export const metadataFor = ({
195
+ origin,
196
+ page,
197
+ path,
198
+ site,
199
+ }: {
200
+ origin: string
201
+ /** This page's stored SEO, and its own title. Null seo means the platform said nothing. */
202
+ page: { seo: null | PageSeo; title: null | string }
203
+ /** The path this page answers at, e.g. `about-us` or `/`. */
204
+ path: string
205
+ site: null | SiteSeo
206
+ }): SeoMetadata => {
207
+ const seo = page.seo
208
+ const indexable = site?.allowIndexing === true && seo?.noindex !== true
209
+ return seoMetadataFor(
210
+ {
211
+ site,
212
+ manifest: null,
213
+ page: seo ? { ...seo, ogImage: seo.ogImage?.url ? { url: seo.ogImage.url, alt: seo.ogImage.alt } : null } : null,
214
+ pageTitle: page.title ?? '',
215
+ origin,
216
+ path,
217
+ },
218
+ { follow: indexable, index: indexable },
219
+ )
220
+ }
221
+
222
+ type OpeningHoursSpec = {
223
+ '@type': 'OpeningHoursSpecification'
224
+ closes: string
225
+ dayOfWeek: OpeningHoursDay[]
226
+ opens: string
227
+ }
228
+
229
+ /**
230
+ * `organisation.openingHours`, as schema.org `OpeningHoursSpecification` entries.
231
+ *
232
+ * A closed day, or a day with no `opens`/`closes` pair, has nothing schema.org can use and is
233
+ * dropped rather than emitted with a null time: Google reports a spec entry with a missing time as
234
+ * an error, which is worse than saying nothing about that day.
235
+ *
236
+ * Days sharing an identical `{opens, closes}` collapse into one entry listing every `dayOfWeek`
237
+ * that applies, which schema.org allows.
238
+ */
239
+ const openingHoursSpecification = (rows: readonly OpeningHoursRow[]): OpeningHoursSpec[] => {
240
+ const groups = new Map<string, OpeningHoursSpec>()
241
+ for (const row of rows) {
242
+ if (row.closed || row.opens === null || row.closes === null) continue
243
+ const key = `${row.opens}|${row.closes}`
244
+ const existing = groups.get(key)
245
+ if (existing) existing.dayOfWeek.push(row.day)
246
+ else groups.set(key, { '@type': 'OpeningHoursSpecification', closes: row.closes, dayOfWeek: [row.day], opens: row.opens })
247
+ }
248
+ return [...groups.values()]
249
+ }
250
+
251
+ /**
252
+ * The organisation, as schema.org JSON-LD, or null.
253
+ *
254
+ * The name is the organisation's `legalName`, else the site name. Without either, or without an
255
+ * organisation, there is none: a business panel with no name is malformed structured data, and
256
+ * Google reports it as an error against the site.
257
+ */
258
+ export const organisationJsonLd = ({ origin, site }: { origin: string; site: null | SiteSeo }): Record<string, unknown> | null => {
259
+ const organisation = site?.organisation
260
+ const name = filled(organisation?.legalName) ?? filled(site?.siteName)
261
+ if (!organisation || !name) return null
262
+
263
+ const openingHoursSpec = openingHoursSpecification(organisation.openingHours)
264
+
265
+ return {
266
+ '@context': 'https://schema.org',
267
+ '@type': 'Organization',
268
+ name,
269
+ url: origin.replace(/\/$/, ''),
270
+ ...(organisation.logo?.url ? { logo: organisation.logo.url } : {}),
271
+ ...(filled(organisation.phone) ? { telephone: organisation.phone } : {}),
272
+ ...(filled(organisation.email) ? { email: organisation.email } : {}),
273
+ ...(filled(organisation.address) ? { address: organisation.address } : {}),
274
+ // Structured hours win when there are any. The free-text field is the fallback for a tenant
275
+ // who has not used the structured editor, emitted the old way, as a string.
276
+ ...(openingHoursSpec.length > 0
277
+ ? { openingHoursSpecification: openingHoursSpec }
278
+ : filled(organisation.legacyOpeningHours)
279
+ ? { openingHours: organisation.legacyOpeningHours }
280
+ : {}),
281
+ ...(organisation.sameAs.length > 0 ? { sameAs: organisation.sameAs } : {}),
282
+ }
283
+ }
284
+
285
+ /**
286
+ * `sitemap.xml`, from the platform's published pages.
287
+ *
288
+ * Empty while `allowIndexing` is off. A sitemap is a request to be crawled, and serving one
289
+ * from a site whose robots.txt disallows everything is a contradiction — the same
290
+ * contradiction, one level up, that dropping `noindex` pages avoids per-page.
291
+ */
292
+ export const sitemapFrom = ({
293
+ entries,
294
+ origin,
295
+ site,
296
+ }: {
297
+ entries: readonly { lastModified: null | string; slug: string }[]
298
+ origin: string
299
+ site: null | SiteSeo
300
+ }): { lastModified?: string; url: string }[] => {
301
+ if (site?.allowIndexing !== true) return []
302
+
303
+ return entries.map((entry) => ({
304
+ ...(entry.lastModified ? { lastModified: entry.lastModified } : {}),
305
+ // `home` is the site root, not `/home` — the same convention `pages.slug` describes.
306
+ url: joinUrl(origin, entry.slug === 'home' ? '/' : entry.slug),
307
+ }))
308
+ }
309
+
310
+ export const robotsFrom = ({
311
+ origin,
312
+ site,
313
+ }: {
314
+ origin: string
315
+ site: null | SiteSeo
316
+ }): { rules: { allow?: string; disallow?: string; userAgent: string }; sitemap?: string } =>
317
+ site?.allowIndexing === true
318
+ ? {
319
+ rules: { allow: '/', userAgent: '*' },
320
+ sitemap: joinUrl(origin, 'sitemap.xml'),
321
+ }
322
+ : /*
323
+ * Disallow everything and advertise no sitemap. This is the default, because
324
+ * `allowIndexing` defaults to false — a prospect site must stay out of Google until
325
+ * somebody decides otherwise.
326
+ */
327
+ { rules: { disallow: '/', userAgent: '*' } }