@crombolo/render 0.0.0-stage → 0.10.0

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/src/seo.ts ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Search and sharing: the values a page offers search engines and social
3
+ * previews, and the `<SeoHead>` that writes them.
4
+ *
5
+ * `effectiveMeta` is the one place the fallbacks live. The page's head and the
6
+ * editor's preview both call it, so what an editor is shown is what is served.
7
+ */
8
+
9
+ import { RawHtml } from './html.ts'
10
+ import { element } from './jsx-runtime.ts'
11
+ import type { PublishedContent } from './model.ts'
12
+
13
+ /** The fields of the "Search & sharing" composition, by role. */
14
+ export const SEO_ALIASES = {
15
+ title: 'seoTitle',
16
+ description: 'seoDescription',
17
+ image: 'seoImage',
18
+ canonical: 'seoCanonical',
19
+ noindex: 'seoNoindex',
20
+ sitemapHide: 'seoSitemapHide',
21
+ } as const
22
+
23
+ /** Site-wide values a page falls back to. */
24
+ export interface SeoSiteDefaults {
25
+ siteName: string
26
+ titleSuffix?: string
27
+ /** An absolute or root-relative image address. */
28
+ defaultImage?: string
29
+ /** False on a site search engines should stay out of entirely, such as staging. */
30
+ indexable: boolean
31
+ }
32
+
33
+ /** What a template supplies when the page itself says nothing. */
34
+ export interface SeoOverrides {
35
+ title?: string
36
+ description?: string
37
+ image?: string
38
+ canonical?: string
39
+ /** `og:type`; `website` when absent. */
40
+ type?: string
41
+ /** Appended to the title; null for none even when the site sets one. */
42
+ titleSuffix?: string | null
43
+ noindex?: boolean
44
+ }
45
+
46
+ /** Where a value came from: the page's own field, the template, the site, or the page name. */
47
+ export type MetaSource = 'page' | 'template' | 'site' | 'name' | 'none'
48
+
49
+ export interface MetaValue<T> {
50
+ value: T
51
+ source: MetaSource
52
+ }
53
+
54
+ export interface Alternate {
55
+ culture: string | null
56
+ url: string
57
+ /** The default language's version, offered as `x-default`. */
58
+ isDefault?: boolean
59
+ }
60
+
61
+ export interface EffectiveMeta {
62
+ /** The title as written, before any suffix. */
63
+ title: MetaValue<string>
64
+ /** The `<title>`: the title plus the suffix. */
65
+ fullTitle: string
66
+ description: MetaValue<string | undefined>
67
+ image: MetaValue<string | undefined>
68
+ canonical: MetaValue<string | undefined>
69
+ noindex: MetaValue<boolean>
70
+ /** Language versions with an address, absolute; empty unless there are two or more. */
71
+ alternates: Array<{ culture: string; url: string }>
72
+ xDefault: string | undefined
73
+ siteName: string
74
+ type: string
75
+ }
76
+
77
+ export interface MetaContext {
78
+ origin: string
79
+ alternates: readonly Alternate[]
80
+ site: SeoSiteDefaults
81
+ }
82
+
83
+ /**
84
+ * An address made absolute against the request's origin. `#` is a page with no
85
+ * address in this culture, so it has no absolute form either.
86
+ */
87
+ export function absoluteUrl(url: string | undefined, origin: string): string | undefined {
88
+ if (!url || url === '#') return undefined
89
+ if (/^https?:\/\//i.test(url)) return url
90
+ const base = origin.replace(/\/$/, '')
91
+ if (url.startsWith('//')) return `${new URL(base).protocol}${url}`
92
+ return `${base}${url.startsWith('/') ? '' : '/'}${url}`
93
+ }
94
+
95
+ const filled = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
96
+
97
+ function truthy(value: unknown): boolean {
98
+ return value === true || value === 1 || value === '1' || value === 'true'
99
+ }
100
+
101
+ /** The values a page offers, each with where it came from. */
102
+ export function effectiveMeta(
103
+ content: PublishedContent,
104
+ context: MetaContext,
105
+ overrides: SeoOverrides = {},
106
+ ): EffectiveMeta {
107
+ const { origin, site } = context
108
+ const own = (alias: string) => content.text(alias).trim()
109
+
110
+ const title: MetaValue<string> = filled(own(SEO_ALIASES.title))
111
+ ? { value: own(SEO_ALIASES.title), source: 'page' }
112
+ : filled(overrides.title)
113
+ ? { value: overrides.title, source: 'template' }
114
+ : { value: content.name, source: 'name' }
115
+ const suffix =
116
+ overrides.titleSuffix === null ? '' : (overrides.titleSuffix ?? site.titleSuffix ?? '')
117
+
118
+ const description: MetaValue<string | undefined> = filled(own(SEO_ALIASES.description))
119
+ ? { value: own(SEO_ALIASES.description), source: 'page' }
120
+ : filled(overrides.description)
121
+ ? { value: overrides.description, source: 'template' }
122
+ : { value: undefined, source: 'none' }
123
+
124
+ const picked = content.media(SEO_ALIASES.image)[0]?.url
125
+ const image: MetaValue<string | undefined> = filled(picked)
126
+ ? { value: absoluteUrl(picked, origin), source: 'page' }
127
+ : filled(overrides.image)
128
+ ? { value: absoluteUrl(overrides.image, origin), source: 'template' }
129
+ : filled(site.defaultImage)
130
+ ? { value: absoluteUrl(site.defaultImage, origin), source: 'site' }
131
+ : { value: undefined, source: 'none' }
132
+
133
+ const canonical: MetaValue<string | undefined> = filled(own(SEO_ALIASES.canonical))
134
+ ? { value: absoluteUrl(own(SEO_ALIASES.canonical), origin), source: 'page' }
135
+ : filled(overrides.canonical)
136
+ ? { value: absoluteUrl(overrides.canonical, origin), source: 'template' }
137
+ : { value: absoluteUrl(content.url, origin), source: 'name' }
138
+
139
+ const noindex: MetaValue<boolean> = !site.indexable
140
+ ? { value: true, source: 'site' }
141
+ : truthy(content.value(SEO_ALIASES.noindex))
142
+ ? { value: true, source: 'page' }
143
+ : overrides.noindex
144
+ ? { value: true, source: 'template' }
145
+ : { value: false, source: 'none' }
146
+
147
+ const versions = context.alternates.flatMap((a) => {
148
+ const url = absoluteUrl(a.url, origin)
149
+ return a.culture && url ? [{ culture: a.culture, url, isDefault: a.isDefault === true }] : []
150
+ })
151
+ const alternates =
152
+ versions.length > 1 ? versions.map(({ culture, url }) => ({ culture, url })) : []
153
+ const xDefault = alternates.length > 0 ? versions.find((v) => v.isDefault)?.url : undefined
154
+
155
+ return {
156
+ title,
157
+ fullTitle: `${title.value}${suffix}`,
158
+ description,
159
+ image,
160
+ canonical,
161
+ noindex,
162
+ alternates,
163
+ xDefault,
164
+ siteName: site.siteName,
165
+ type: overrides.type ?? 'website',
166
+ }
167
+ }
168
+
169
+ /** What `<SeoHead>` needs from the page's props, plus a template's own fallbacks. */
170
+ export interface SeoHeadProps extends SeoOverrides {
171
+ model: PublishedContent
172
+ request: { origin: string; url: string }
173
+ urls: { alternates(content: PublishedContent): Alternate[] }
174
+ seo: SeoSiteDefaults
175
+ }
176
+
177
+ /** The head tags for search and sharing. Spread a page's props into it: `<SeoHead {...props} />`. */
178
+ export function SeoHead(props: SeoHeadProps): RawHtml {
179
+ const { model, request, urls, seo, ...overrides } = props
180
+ const meta = effectiveMeta(
181
+ model,
182
+ { origin: request.origin, alternates: urls.alternates(model), site: seo },
183
+ overrides,
184
+ )
185
+ const tag = (name: string, attributes: Record<string, string | undefined>) =>
186
+ Object.values(attributes).some((v) => v === undefined) ? '' : element(name, attributes).value
187
+ const property = (name: string, value: string | undefined) =>
188
+ tag('meta', { property: name, content: value })
189
+ const named = (name: string, value: string | undefined) => tag('meta', { name, content: value })
190
+ return new RawHtml(
191
+ [
192
+ element('title', { children: meta.fullTitle }).value,
193
+ named('description', meta.description.value),
194
+ meta.noindex.value ? named('robots', 'noindex') : '',
195
+ tag('link', { rel: 'canonical', href: meta.canonical.value }),
196
+ ...meta.alternates.map((a) =>
197
+ tag('link', { rel: 'alternate', hreflang: a.culture, href: a.url }),
198
+ ),
199
+ tag('link', { rel: 'alternate', hreflang: 'x-default', href: meta.xDefault }),
200
+ property('og:title', meta.title.value),
201
+ property('og:description', meta.description.value),
202
+ property('og:image', meta.image.value),
203
+ property('og:url', meta.canonical.value),
204
+ property('og:type', meta.type),
205
+ meta.siteName ? property('og:site_name', meta.siteName) : '',
206
+ named('twitter:card', meta.image.value ? 'summary_large_image' : 'summary'),
207
+ ].join(''),
208
+ )
209
+ }
package/src/sitemap.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `/sitemap.xml` and `/robots.txt`: the pages a hostname offers search engines,
3
+ * and the file that points them at the list.
4
+ */
5
+ import { escapeHtml } from './html.ts'
6
+ import type { PublishedContent } from './model.ts'
7
+ import type { CacheSnapshot } from './published-cache.ts'
8
+ import { absoluteUrl, SEO_ALIASES } from './seo.ts'
9
+
10
+ export interface SitemapEntry {
11
+ content: PublishedContent
12
+ url: string
13
+ lastmod: Date
14
+ /** The page's other language versions, absolute, when it has more than one. */
15
+ alternates: Array<{ culture: string; url: string }>
16
+ }
17
+
18
+ /** The hostname part of a `//host/route` address. */
19
+ const hostOf = (url: string): string => url.slice(2).split('/')[0]?.toLowerCase() ?? ''
20
+
21
+ const hidden = (content: PublishedContent): boolean => {
22
+ const value = content.value(SEO_ALIASES.sitemapHide)
23
+ return value === true || value === 1 || value === '1' || value === 'true'
24
+ }
25
+
26
+ /**
27
+ * Every page a request to `host` can reach, once, with its language versions.
28
+ * Pages without a template, without an address in a culture, or marked hidden
29
+ * are left out; so is anything rooted at another hostname.
30
+ */
31
+ export function sitemapEntries(
32
+ snapshot: CacheSnapshot,
33
+ options: { host: string; origin: string },
34
+ ): SitemapEntry[] {
35
+ const host = options.host.toLowerCase()
36
+ const bare = host.replace(/:\d+$/, '')
37
+ const hostnames = new Set(snapshot.domains.map((d) => d.domainName.split('/')[0]?.toLowerCase()))
38
+ const onThisHost = hostnames.has(host) || hostnames.has(bare)
39
+ const belongs = (url: string): boolean =>
40
+ url.startsWith('//') ? [host, bare].includes(hostOf(url)) : url !== '#' && !onThisHost
41
+
42
+ const versionsOf = new Map<string, Array<{ culture: string | null; url: string }>>()
43
+ for (const view of snapshot.views.values())
44
+ for (const content of view.byId.values()) {
45
+ const versions = versionsOf.get(content.key) ?? []
46
+ versions.push({ culture: view.culture, url: content.url })
47
+ versionsOf.set(content.key, versions)
48
+ }
49
+
50
+ const entries: SitemapEntry[] = []
51
+ const seen = new Set<string>()
52
+ for (const view of snapshot.views.values())
53
+ for (const content of view.byId.values()) {
54
+ if (!content.componentAlias || !belongs(content.url) || hidden(content)) continue
55
+ const url = absoluteUrl(content.url, options.origin)
56
+ if (!url || seen.has(url)) continue
57
+ seen.add(url)
58
+ const alternates = (versionsOf.get(content.key) ?? []).flatMap((v) => {
59
+ const absolute = absoluteUrl(v.url, options.origin)
60
+ return v.culture && absolute ? [{ culture: v.culture, url: absolute }] : []
61
+ })
62
+ entries.push({
63
+ content,
64
+ url,
65
+ lastmod: content.updateDate,
66
+ alternates: alternates.length > 1 ? alternates : [],
67
+ })
68
+ }
69
+ return entries
70
+ }
71
+
72
+ export function renderSitemap(entries: readonly SitemapEntry[]): string {
73
+ const urls = entries.map((entry) => {
74
+ const links = entry.alternates
75
+ .map(
76
+ (a) =>
77
+ `<xhtml:link rel="alternate" hreflang="${escapeHtml(a.culture)}" href="${escapeHtml(a.url)}"/>`,
78
+ )
79
+ .join('')
80
+ return `<url><loc>${escapeHtml(entry.url)}</loc><lastmod>${entry.lastmod.toISOString()}</lastmod>${links}</url>`
81
+ })
82
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n${urls.join('\n')}\n</urlset>\n`
83
+ }
84
+
85
+ /**
86
+ * The robots file: the site's own rules, refusing everything on a site that
87
+ * should stay out of search, and the sitemap's address when there is one to offer.
88
+ */
89
+ export function robotsTxt(
90
+ text: string,
91
+ options: { origin: string; indexable: boolean; sitemap: boolean },
92
+ ): string {
93
+ const rules = options.indexable ? text.trim() : 'User-agent: *\nDisallow: /'
94
+ const sitemap =
95
+ options.sitemap && options.indexable
96
+ ? `\n\nSitemap: ${options.origin.replace(/\/$/, '')}/sitemap.xml`
97
+ : ''
98
+ return `${rules}${sitemap}\n`
99
+ }