@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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +36 -4
- package/src/context.ts +112 -0
- package/src/form-script.ts +112 -0
- package/src/forms.ts +557 -0
- package/src/html.ts +285 -0
- package/src/index.ts +16 -0
- package/src/jsx-attributes.ts +91 -0
- package/src/jsx-runtime.ts +334 -0
- package/src/loose/jsx-runtime.ts +21 -0
- package/src/media.ts +205 -0
- package/src/model.ts +303 -0
- package/src/published-cache.ts +865 -0
- package/src/redirects.ts +131 -0
- package/src/renderer.ts +374 -0
- package/src/seo.ts +209 -0
- package/src/sitemap.ts +99 -0
- package/src/snapshots.ts +398 -0
- package/src/url-segment.ts +34 -0
- package/src/values.ts +325 -0
- package/README.md +0 -3
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
|
+
}
|