@unitninety/site-kit 0.0.0-stage → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1010 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +271 -0
- package/src/bridge/registry.ts +27 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +12 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,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: '*' } }
|