starlight-seo 0.2.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/index.js ADDED
@@ -0,0 +1,66 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { audit, report } from './lib/audit.js'
3
+ import { normalize } from './lib/options.js'
4
+
5
+ const OPTIONS = 'virtual:starlight-seo/options'
6
+ const EXTEND = 'virtual:starlight-seo/extend'
7
+
8
+ const virtual = (options, root) => {
9
+ const extend = options.extend ? fileURLToPath(new URL(options.extend, root)) : null
10
+ return {
11
+ name: 'starlight-seo:virtual',
12
+ resolveId(id) {
13
+ if (id === OPTIONS || id === EXTEND) return `\0${id}`
14
+ },
15
+ load(id) {
16
+ if (id === `\0${OPTIONS}`) return `export default ${JSON.stringify(options)}`
17
+ if (id === `\0${EXTEND}`) {
18
+ return extend
19
+ ? `import * as user from ${JSON.stringify(extend)}\nconst hooks = { ...user }\nexport const page = hooks.page\nexport const graph = hooks.graph`
20
+ : 'export const page = undefined\nexport const graph = undefined'
21
+ }
22
+ },
23
+ }
24
+ }
25
+
26
+ export default function starlightSeo(userOptions = {}) {
27
+ return {
28
+ name: 'starlight-seo',
29
+ hooks: {
30
+ 'config:setup'({ config, astroConfig, addRouteMiddleware, addIntegration }) {
31
+ const root = !config.defaultLocale || config.defaultLocale === 'root'
32
+ const options = normalize(userOptions, {
33
+ site: astroConfig.site,
34
+ delimiter: config.titleDelimiter,
35
+ defaultPrefix: root ? '' : `/${config.defaultLocale}`,
36
+ })
37
+
38
+ addRouteMiddleware({ entrypoint: 'starlight-seo/middleware', order: 'post' })
39
+
40
+ addIntegration({
41
+ name: 'starlight-seo',
42
+ hooks: {
43
+ 'astro:config:setup': ({ config: astro, updateConfig }) => {
44
+ updateConfig({
45
+ vite: {
46
+ plugins: [virtual(options, astro.root)],
47
+ ssr: { noExternal: ['starlight-seo'] },
48
+ },
49
+ })
50
+ },
51
+ 'astro:build:done': async ({ dir, logger }) => {
52
+ if (options.audit.failOn === 'off') return
53
+ const result = await audit(fileURLToPath(dir), options)
54
+ report(result, logger, options.audit.limit)
55
+ const failed = result.errors > 0 || (options.audit.failOn === 'warn' && result.warnings > 0)
56
+ if (failed) throw new Error(`[starlight-seo] audit failed: ${result.errors} error(s), ${result.warnings} warning(s)`)
57
+ },
58
+ },
59
+ })
60
+ },
61
+ },
62
+ }
63
+ }
64
+
65
+ export { audit } from './lib/audit.js'
66
+ export { normalize } from './lib/options.js'
package/lib/audit.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ export interface AuditFinding {
2
+ rule: string
3
+ level: 'error' | 'warn'
4
+ path: string
5
+ message: string
6
+ }
7
+
8
+ export interface AuditResult {
9
+ pages: number
10
+ findings: AuditFinding[]
11
+ errors: number
12
+ warnings: number
13
+ }
14
+
15
+ export function audit(root: string, options: unknown): Promise<AuditResult>
16
+ export function inspect(html: string): Record<string, unknown>
17
+ export function report(result: AuditResult, logger: { info(message: string): void; warn(message: string): void; error(message: string): void }, limit?: number): void
package/lib/audit.js ADDED
@@ -0,0 +1,164 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile, readdir } from 'node:fs/promises'
3
+ import { join, relative, resolve, sep } from 'node:path'
4
+ import { anyMatch, length } from './text.js'
5
+
6
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
7
+
8
+ const decode = (value) =>
9
+ String(value)
10
+ .replace(/&#x([0-9a-f]+);/gi, (whole, code) => String.fromCodePoint(parseInt(code, 16)))
11
+ .replace(/&#(\d+);/g, (whole, code) => String.fromCodePoint(Number(code)))
12
+ .replace(/&(\w+);/g, (whole, name) => ENTITIES[name] ?? whole)
13
+
14
+ const attributes = (source) => {
15
+ const result = {}
16
+ for (const match of source.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) result[match[1].toLowerCase()] = decode(match[2] ?? match[3])
17
+ return result
18
+ }
19
+
20
+ async function* walk(directory) {
21
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
22
+ const full = join(directory, entry.name)
23
+ if (entry.isDirectory()) yield* walk(full)
24
+ else if (entry.name.endsWith('.html')) yield full
25
+ }
26
+ }
27
+
28
+ const routeOf = (root, file) => {
29
+ const path = `/${relative(root, file).split(sep).join('/')}`
30
+ return path.endsWith('/index.html') ? path.slice(0, -'index.html'.length) : path
31
+ }
32
+
33
+ const trim = (value) => value.replace(/(index)?\.html$/, '').replace(/\/+$/, '') || '/'
34
+
35
+ const sameRoute = (canonical, path) => {
36
+ try {
37
+ return trim(decodeURIComponent(new URL(canonical).pathname)) === trim(path)
38
+ } catch {
39
+ return true
40
+ }
41
+ }
42
+
43
+ const localPath = (href) => {
44
+ const { pathname } = new URL(href)
45
+ try {
46
+ return decodeURIComponent(pathname)
47
+ } catch {
48
+ return pathname
49
+ }
50
+ }
51
+
52
+ export function inspect(html) {
53
+ const end = html.search(/<\/head>/i)
54
+ const head = end === -1 ? html : html.slice(0, end)
55
+ const tags = [...head.matchAll(/<(meta|link)\b([^>]*)>/gi)].map((match) => ({ tag: match[1].toLowerCase(), attrs: attributes(match[2]) }))
56
+ const meta = (key, value) => tags.find((entry) => entry.tag === 'meta' && entry.attrs[key] === value)?.attrs.content
57
+ const title = head.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)
58
+ const scripts = [...html.matchAll(/<script\b[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)].map((match) => match[1])
59
+ const nodes = []
60
+ let invalid = 0
61
+ for (const script of scripts) {
62
+ try {
63
+ const parsed = JSON.parse(script)
64
+ for (const item of [].concat(parsed)) nodes.push(...(Array.isArray(item?.['@graph']) ? item['@graph'] : [item]))
65
+ } catch {
66
+ invalid += 1
67
+ }
68
+ }
69
+ return {
70
+ title: title ? decode(title[1]).trim() : undefined,
71
+ description: meta('name', 'description'),
72
+ ogTitle: meta('property', 'og:title'),
73
+ ogImage: meta('property', 'og:image'),
74
+ robots: meta('name', 'robots'),
75
+ canonical: tags.find((entry) => entry.tag === 'link' && entry.attrs.rel === 'canonical')?.attrs.href,
76
+ redirect: tags.some((entry) => entry.tag === 'meta' && entry.attrs['http-equiv']?.toLowerCase() === 'refresh'),
77
+ scripts: scripts.length,
78
+ invalid,
79
+ nodes,
80
+ }
81
+ }
82
+
83
+ export async function audit(root, options) {
84
+ const levels = options.audit.rules
85
+ const excluded = [...options.exclude, ...options.audit.exclude]
86
+ const findings = []
87
+ const add = (rule, path, message) => {
88
+ if (levels[rule] !== 'off') findings.push({ rule, level: levels[rule], path, message })
89
+ }
90
+ const titles = new Map()
91
+ const descriptions = new Map()
92
+ let pages = 0
93
+
94
+ for await (const file of walk(root)) {
95
+ const path = routeOf(root, file)
96
+ if (anyMatch(excluded, path)) continue
97
+ const page = inspect(await readFile(file, 'utf8'))
98
+ if (page.redirect || /\bnoindex\b/i.test(page.robots ?? '')) continue
99
+ if (page.canonical && !sameRoute(page.canonical, path)) continue
100
+ pages += 1
101
+
102
+ if (!page.title) add('title.missing', path, 'no <title>')
103
+ else {
104
+ const size = length(page.title)
105
+ if (size < options.title.min) add('title.short', path, `${size} < ${options.title.min}: ${page.title}`)
106
+ if (size > options.title.max) add('title.long', path, `${size} > ${options.title.max}: ${page.title}`)
107
+ titles.set(page.title, [...(titles.get(page.title) ?? []), path])
108
+ }
109
+
110
+ if (!page.description) add('description.missing', path, 'no meta description')
111
+ else {
112
+ const size = length(page.description)
113
+ if (size < options.description.min) add('description.short', path, `${size} < ${options.description.min}`)
114
+ if (size > options.description.max) add('description.long', path, `${size} > ${options.description.max}`)
115
+ descriptions.set(page.description, [...(descriptions.get(page.description) ?? []), path])
116
+ }
117
+
118
+ if (!page.canonical) add('canonical.missing', path, 'no canonical link')
119
+
120
+ if (page.invalid > 0) add('jsonld.invalid', path, `${page.invalid} JSON-LD block(s) do not parse`)
121
+ if (page.scripts === 0) add('jsonld.missing', path, 'no JSON-LD')
122
+ for (const node of page.nodes) {
123
+ const id = String(node?.['@id'] ?? '')
124
+ const name = id.endsWith('#article') ? node.headline : id.endsWith('#webpage') ? node.name : undefined
125
+ if (name !== undefined && page.ogTitle !== undefined && name !== page.ogTitle) {
126
+ add('jsonld.mismatch', path, `${id} says "${name}", og:title says "${page.ogTitle}"`)
127
+ }
128
+ }
129
+
130
+ if (!page.ogImage) add('image.missing', path, 'no og:image')
131
+ else if (page.ogImage.startsWith(`${options.origin}/`)) {
132
+ const local = localPath(page.ogImage)
133
+ const target = resolve(root, `.${local}`)
134
+ const inside = target.startsWith(`${resolve(root)}${sep}`)
135
+ if (!inside || !existsSync(target)) add('image.broken', path, `${local} is not in the build output`)
136
+ }
137
+ }
138
+
139
+ for (const [title, paths] of titles) {
140
+ if (paths.length > 1) for (const path of paths) add('title.duplicate', path, `shared by ${paths.length} pages: ${title}`)
141
+ }
142
+ for (const [, paths] of descriptions) {
143
+ if (paths.length > 1) for (const path of paths) add('description.duplicate', path, `shared by ${paths.length} pages`)
144
+ }
145
+
146
+ return {
147
+ pages,
148
+ findings,
149
+ errors: findings.filter((finding) => finding.level === 'error').length,
150
+ warnings: findings.filter((finding) => finding.level === 'warn').length,
151
+ }
152
+ }
153
+
154
+ export function report(result, logger, limit = 15) {
155
+ const groups = new Map()
156
+ for (const finding of result.findings) groups.set(finding.rule, [...(groups.get(finding.rule) ?? []), finding])
157
+ for (const [rule, list] of groups) {
158
+ const write = list[0].level === 'error' ? logger.error.bind(logger) : logger.warn.bind(logger)
159
+ write(`${rule}: ${list.length} page(s)`)
160
+ for (const finding of list.slice(0, limit)) write(` ${finding.path} ${finding.message}`)
161
+ if (list.length > limit) write(` ... and ${list.length - limit} more`)
162
+ }
163
+ logger.info(`audited ${result.pages} page(s): ${result.errors} error(s), ${result.warnings} warning(s)`)
164
+ }
@@ -0,0 +1,33 @@
1
+ const firstLink = (entry) => {
2
+ for (const child of entry.entries ?? []) {
3
+ if (child.type === 'link' && child.href) return child.href
4
+ const nested = firstLink(child)
5
+ if (nested) return nested
6
+ }
7
+ return undefined
8
+ }
9
+
10
+ const walk = (entries, trail) => {
11
+ for (const entry of entries ?? []) {
12
+ if (entry.type === 'link' && entry.isCurrent) return [...trail, { label: entry.label, href: entry.href, current: true }]
13
+ if (entry.type === 'group') {
14
+ const found = walk(entry.entries, [...trail, { label: entry.label, href: firstLink(entry), group: true }])
15
+ if (found) return found
16
+ }
17
+ }
18
+ return null
19
+ }
20
+
21
+ const distinct = (crumbs) => crumbs.filter((crumb, index) => !crumb.href || crumb.href !== crumbs[index - 1]?.href)
22
+
23
+ export function breadcrumbs({ sidebar, home, label, pathname, isHome, groups }) {
24
+ if (isHome) return []
25
+ const found = walk(sidebar, []) ?? [{ label, href: pathname, current: true }]
26
+ const current = found.at(-1)
27
+ const middle = found
28
+ .filter((crumb) => !crumb.current)
29
+ .filter(() => groups !== 'skip')
30
+ .map((crumb) => (groups === 'plain' ? { label: crumb.label } : { label: crumb.label, href: crumb.href }))
31
+ .filter((crumb) => crumb.href !== pathname)
32
+ return [{ label: home.label, href: home.href }, ...distinct(middle), { label: current.label ?? label, href: pathname }]
33
+ }
package/lib/graph.js ADDED
@@ -0,0 +1,138 @@
1
+ const ARTICLES = new Set([
2
+ 'Article',
3
+ 'TechArticle',
4
+ 'APIReference',
5
+ 'BlogPosting',
6
+ 'NewsArticle',
7
+ 'Report',
8
+ 'ScholarlyArticle',
9
+ 'SocialMediaPosting',
10
+ 'DiscussionForumPosting',
11
+ ])
12
+
13
+ export const isArticle = (type) => ARTICLES.has(type)
14
+
15
+ const day = (value) => (value instanceof Date ? value.toISOString().slice(0, 10) : value ? String(value).slice(0, 10) : undefined)
16
+
17
+ const compact = (node) => Object.fromEntries(Object.entries(node).filter(([, value]) => value !== undefined && value !== null && value !== ''))
18
+
19
+ const thing = (value) => {
20
+ if (!value) return undefined
21
+ if (typeof value === 'string') return { '@type': 'Thing', name: value }
22
+ return compact({ '@type': value.type ?? 'Thing', name: value.name, description: value.description, url: value.url, sameAs: value.sameAs })
23
+ }
24
+
25
+ const things = (value) => {
26
+ if (!value) return undefined
27
+ const list = [].concat(value).map(thing).filter(Boolean)
28
+ return list.length === 0 ? undefined : list.length === 1 ? list[0] : list
29
+ }
30
+
31
+ export const ids = (site, publisher, url) => ({
32
+ website: `${site.origin}/#website`,
33
+ publisher: publisher.id ?? `${publisher.url ?? `${site.origin}/`}#${publisher.type === 'Person' ? 'person' : 'organization'}`,
34
+ webpage: `${url}#webpage`,
35
+ article: `${url}#article`,
36
+ breadcrumb: `${url}#breadcrumb`,
37
+ image: `${url}#primaryimage`,
38
+ })
39
+
40
+ export function buildGraph({ site, publisher, page }) {
41
+ const id = ids(site, publisher, page.url)
42
+ const publisherRef = { '@id': id.publisher }
43
+ const article = isArticle(page.type)
44
+ const hasTrail = page.breadcrumbs.length > 1
45
+ const about = things(page.about)
46
+
47
+ const nodes = [
48
+ compact({
49
+ '@type': 'WebSite',
50
+ '@id': id.website,
51
+ url: `${site.origin}/`,
52
+ name: site.name,
53
+ alternateName: site.alternateName,
54
+ description: site.description,
55
+ inLanguage: page.language,
56
+ publisher: publisherRef,
57
+ about: things(site.about),
58
+ }),
59
+ compact({
60
+ '@type': publisher.type ?? 'Organization',
61
+ '@id': id.publisher,
62
+ name: publisher.name,
63
+ url: publisher.url,
64
+ logo: publisher.logo ? { '@type': 'ImageObject', url: publisher.logo } : undefined,
65
+ sameAs: publisher.sameAs,
66
+ }),
67
+ compact({
68
+ '@type': article ? 'WebPage' : page.type,
69
+ '@id': id.webpage,
70
+ url: page.url,
71
+ name: page.title,
72
+ description: page.description,
73
+ inLanguage: page.language,
74
+ isPartOf: { '@id': id.website },
75
+ breadcrumb: hasTrail ? { '@id': id.breadcrumb } : undefined,
76
+ primaryImageOfPage: page.image ? { '@id': id.image } : undefined,
77
+ datePublished: day(page.published),
78
+ dateModified: day(page.modified),
79
+ about: article ? undefined : about,
80
+ }),
81
+ ]
82
+
83
+ if (page.image) {
84
+ nodes.push(
85
+ compact({
86
+ '@type': 'ImageObject',
87
+ '@id': id.image,
88
+ url: page.image.url,
89
+ contentUrl: page.image.url,
90
+ width: page.image.width,
91
+ height: page.image.height,
92
+ caption: page.image.alt,
93
+ }),
94
+ )
95
+ }
96
+
97
+ if (article) {
98
+ nodes.push(
99
+ compact({
100
+ '@type': page.type,
101
+ '@id': id.article,
102
+ headline: page.title,
103
+ description: page.description,
104
+ inLanguage: page.language,
105
+ url: page.url,
106
+ mainEntityOfPage: { '@id': id.webpage },
107
+ isPartOf: { '@id': id.webpage },
108
+ author: publisherRef,
109
+ publisher: publisherRef,
110
+ datePublished: day(page.published),
111
+ dateModified: day(page.modified ?? page.published),
112
+ image: page.image ? { '@id': id.image } : undefined,
113
+ articleSection: page.section,
114
+ keywords: page.keywords?.length ? page.keywords.join(', ') : undefined,
115
+ about,
116
+ }),
117
+ )
118
+ }
119
+
120
+ if (hasTrail) {
121
+ nodes.push({
122
+ '@type': 'BreadcrumbList',
123
+ '@id': id.breadcrumb,
124
+ itemListElement: page.breadcrumbs.map((crumb, index) =>
125
+ compact({
126
+ '@type': 'ListItem',
127
+ position: index + 1,
128
+ name: crumb.label,
129
+ item: crumb.href ? new URL(crumb.href, site.origin).href : undefined,
130
+ }),
131
+ ),
132
+ })
133
+ }
134
+
135
+ return nodes
136
+ }
137
+
138
+ export const wrap = (nodes) => ({ '@context': 'https://schema.org', '@graph': nodes })
package/lib/head.js ADDED
@@ -0,0 +1,72 @@
1
+ import { buildGraph, wrap } from './graph.js'
2
+ import { serialize } from './text.js'
3
+
4
+ const isMeta = (entry, key, value) => entry.tag === 'meta' && entry.attrs?.[key] === value
5
+
6
+ const setMeta = (head, key, value, content) => {
7
+ const existing = head.find((entry) => isMeta(entry, key, value))
8
+ if (content === undefined || content === null || content === '') {
9
+ if (existing) head.splice(head.indexOf(existing), 1)
10
+ return
11
+ }
12
+ if (existing) existing.attrs.content = String(content)
13
+ else head.push({ tag: 'meta', attrs: { [key]: value, content: String(content) } })
14
+ }
15
+
16
+ const iso = (value) => (value instanceof Date ? value.toISOString() : value ? new Date(value).toISOString() : undefined)
17
+
18
+ export const canonicalOf = (head) => {
19
+ const link = head.find((entry) => entry.tag === 'link' && entry.attrs?.rel === 'canonical')
20
+ return typeof link?.attrs?.href === 'string' ? link.attrs.href : undefined
21
+ }
22
+
23
+ export const setCanonical = (head, href) => {
24
+ const link = head.find((entry) => entry.tag === 'link' && entry.attrs?.rel === 'canonical')
25
+ if (link) link.attrs.href = href
26
+ else head.push({ tag: 'link', attrs: { rel: 'canonical', href } })
27
+ setMeta(head, 'property', 'og:url', href)
28
+ }
29
+
30
+ export function applyHead(head, page, options) {
31
+ const title = head.find((entry) => entry.tag === 'title')
32
+ if (title) title.content = page.headTitle
33
+ else head.push({ tag: 'title', content: page.headTitle })
34
+
35
+ setMeta(head, 'property', 'og:title', page.title)
36
+ setMeta(head, 'name', 'description', page.description)
37
+ setMeta(head, 'property', 'og:description', page.description)
38
+ setMeta(head, 'property', 'og:type', page.article ? 'article' : 'website')
39
+
40
+ if (page.image) {
41
+ setMeta(head, 'property', 'og:image', page.image.url)
42
+ setMeta(head, 'property', 'og:image:width', page.image.width)
43
+ setMeta(head, 'property', 'og:image:height', page.image.height)
44
+ setMeta(head, 'property', 'og:image:alt', page.image.alt)
45
+ setMeta(head, 'name', 'twitter:image', page.image.url)
46
+ setMeta(head, 'name', 'twitter:image:alt', page.image.alt)
47
+ } else {
48
+ setMeta(head, 'name', 'twitter:card', 'summary')
49
+ }
50
+
51
+ if (page.article) {
52
+ setMeta(head, 'property', 'article:published_time', iso(page.published))
53
+ setMeta(head, 'property', 'article:modified_time', iso(page.modified))
54
+ }
55
+
56
+ const hasRobots = head.some((entry) => isMeta(entry, 'name', 'robots'))
57
+ if (page.noindex) setMeta(head, 'name', 'robots', 'noindex, follow')
58
+ else if (options.robots && !hasRobots) setMeta(head, 'name', 'robots', options.robots)
59
+ }
60
+
61
+ export function graphOf(page, options) {
62
+ const publisher = {
63
+ ...options.publisher,
64
+ name: options.publisher.name ?? page.site.name,
65
+ url: options.publisher.url ?? `${page.site.origin}/`,
66
+ }
67
+ return buildGraph({ site: page.site, publisher, page })
68
+ }
69
+
70
+ export function pushGraph(head, nodes) {
71
+ head.push({ tag: 'script', attrs: { type: 'application/ld+json' }, content: serialize(wrap(nodes)) })
72
+ }
package/lib/options.js ADDED
@@ -0,0 +1,87 @@
1
+ const LEVELS = ['error', 'warn', 'off']
2
+
3
+ export const RULES = {
4
+ 'title.missing': 'error',
5
+ 'title.short': 'warn',
6
+ 'title.long': 'warn',
7
+ 'title.duplicate': 'error',
8
+ 'description.missing': 'error',
9
+ 'description.short': 'warn',
10
+ 'description.long': 'warn',
11
+ 'description.duplicate': 'error',
12
+ 'canonical.missing': 'error',
13
+ 'jsonld.missing': 'warn',
14
+ 'jsonld.invalid': 'error',
15
+ 'jsonld.mismatch': 'error',
16
+ 'image.missing': 'warn',
17
+ 'image.broken': 'error',
18
+ }
19
+
20
+ const fail = (message) => {
21
+ throw new Error(`[starlight-seo] ${message}`)
22
+ }
23
+
24
+ export function normalize(user = {}, context = {}) {
25
+ const origin = context.site ? String(context.site).replace(/\/+$/, '') : undefined
26
+ if (!origin) fail('set `site` in the Astro config: canonical URLs and JSON-LD identifiers need an absolute origin')
27
+
28
+ const title = user.title ?? {}
29
+ const description = user.description ?? {}
30
+ const audit = user.audit === false ? { failOn: 'off' } : (user.audit ?? {})
31
+ const rules = { ...RULES, ...(audit.rules ?? {}) }
32
+ for (const [rule, level] of Object.entries(rules)) {
33
+ if (!(rule in RULES)) fail(`unknown audit rule "${rule}"`)
34
+ if (!LEVELS.includes(level)) fail(`audit rule "${rule}" has level "${level}", expected one of ${LEVELS.join(', ')}`)
35
+ }
36
+ const brand = title.brand ?? 'auto'
37
+ if (!['auto', 'always', 'never'].includes(brand)) fail('`title.brand` must be "auto", "always" or "never"')
38
+ const groups = user.breadcrumbs?.groups ?? 'link'
39
+ if (!['link', 'plain', 'skip'].includes(groups)) fail('`breadcrumbs.groups` must be "link", "plain" or "skip"')
40
+
41
+ if (!['canonical', 'keep'].includes(user.fallback ?? 'canonical')) fail('`fallback` must be "canonical" or "keep"')
42
+
43
+ const image = typeof user.image === 'string' ? { src: user.image } : user.image
44
+ const publisher = user.publisher ?? {}
45
+
46
+ return {
47
+ origin,
48
+ site: {
49
+ name: user.site?.name,
50
+ alternateName: user.site?.alternateName,
51
+ description: user.site?.description,
52
+ about: user.site?.about,
53
+ },
54
+ publisher: {
55
+ type: publisher.type ?? 'Organization',
56
+ id: publisher.id,
57
+ name: publisher.name,
58
+ url: publisher.url,
59
+ logo: publisher.logo,
60
+ sameAs: publisher.sameAs,
61
+ },
62
+ title: {
63
+ min: title.min ?? 30,
64
+ max: title.max ?? 60,
65
+ brand,
66
+ delimiter: title.delimiter ?? context.delimiter ?? '|',
67
+ templates: title.templates ?? [],
68
+ site: title.site,
69
+ },
70
+ description: { min: description.min ?? 70, max: description.max ?? 160 },
71
+ types: user.types ?? [],
72
+ defaultType: user.defaultType ?? 'TechArticle',
73
+ image: image ? { src: image.src, width: image.width ?? 1200, height: image.height ?? 630, alt: image.alt } : null,
74
+ robots: user.robots === undefined ? 'max-snippet:-1, max-image-preview:large, max-video-preview:-1' : user.robots,
75
+ breadcrumbs: { groups, home: user.breadcrumbs?.home },
76
+ exclude: user.exclude ?? ['/404/', '/404.html'],
77
+ extend: user.extend,
78
+ fallback: user.fallback ?? 'canonical',
79
+ defaultPrefix: context.defaultPrefix ?? '',
80
+ audit: {
81
+ failOn: audit.failOn ?? 'error',
82
+ exclude: audit.exclude ?? [],
83
+ limit: audit.limit ?? 15,
84
+ rules,
85
+ },
86
+ }
87
+ }
package/lib/page.js ADDED
@@ -0,0 +1,75 @@
1
+ import { breadcrumbs } from './breadcrumbs.js'
2
+ import { isArticle } from './graph.js'
3
+ import { resolveTitle } from './title.js'
4
+ import { fill, firstMatch, pick } from './text.js'
5
+
6
+ const slugOf = (path) => path.replace(/^\/+|\/+$/g, '') || 'index'
7
+
8
+ const absolute = (value, origin) => new URL(value, `${origin}/`).href
9
+
10
+ export function retitle(options, page, base) {
11
+ const brand = pick(options.title.site, page.language, page.locale) ?? page.site.name
12
+ const title = resolveTitle({ label: page.label, seoTitle: base, path: page.path, lang: page.language, locale: page.locale, siteName: brand, options: options.title })
13
+ return { title: title.base, headTitle: title.full, titleSource: 'hook' }
14
+ }
15
+
16
+ export function resolvePage(options, input) {
17
+ const { pathname, path, lang, locale, label, siteTitle, seo = {} } = input
18
+ const siteName = pick(options.site.name, lang, locale) ?? siteTitle
19
+ const home = { label: pick(options.breadcrumbs.home, lang, locale) ?? siteName, href: input.homeHref }
20
+
21
+ const brand = pick(options.title.site, lang, locale) ?? siteName
22
+ const title = resolveTitle({ label, seoTitle: seo.title, path, lang, locale, siteName: brand, options: options.title })
23
+ const description = seo.description ?? input.description ?? pick(options.site.description, lang, locale)
24
+
25
+ const rule = firstMatch(options.types, path)
26
+ const type = seo.type ?? rule?.type ?? (input.isHome ? 'WebPage' : input.template === 'splash' ? 'CollectionPage' : options.defaultType)
27
+
28
+ const source = seo.image ?? (options.image ? fill(pick(options.image.src, lang, locale), { slug: slugOf(path), lang: lang ?? '', locale: locale ?? '' }) : undefined)
29
+ const image = source
30
+ ? {
31
+ url: absolute(source, options.origin),
32
+ width: seo.image ? undefined : options.image.width,
33
+ height: seo.image ? undefined : options.image.height,
34
+ alt: seo.imageAlt ?? pick(options.image?.alt, lang, locale) ?? title.base,
35
+ }
36
+ : null
37
+
38
+ return {
39
+ url: input.canonical,
40
+ pathname,
41
+ path,
42
+ language: lang,
43
+ locale,
44
+ isHome: input.isHome,
45
+ label,
46
+ title: title.base,
47
+ headTitle: title.full,
48
+ titleSource: title.source,
49
+ description,
50
+ type,
51
+ article: isArticle(type),
52
+ image,
53
+ noindex: seo.noindex === true,
54
+ published: seo.published,
55
+ modified: seo.modified ?? input.lastUpdated,
56
+ section: seo.section ?? rule?.section,
57
+ keywords: seo.keywords,
58
+ about: seo.about ?? pick(options.site.about, lang, locale),
59
+ breadcrumbs: breadcrumbs({
60
+ sidebar: input.sidebar,
61
+ home,
62
+ label,
63
+ pathname,
64
+ isHome: input.isHome,
65
+ groups: options.breadcrumbs.groups,
66
+ }),
67
+ site: {
68
+ origin: options.origin,
69
+ name: siteName,
70
+ alternateName: pick(options.site.alternateName, lang, locale),
71
+ description: pick(options.site.description, lang, locale),
72
+ about: pick(options.site.about, lang, locale),
73
+ },
74
+ }
75
+ }