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/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +462 -0
- package/README.ru.md +462 -0
- package/core.d.ts +35 -0
- package/core.js +5 -0
- package/index.d.ts +105 -0
- package/index.js +66 -0
- package/lib/audit.d.ts +17 -0
- package/lib/audit.js +164 -0
- package/lib/breadcrumbs.js +33 -0
- package/lib/graph.js +138 -0
- package/lib/head.js +72 -0
- package/lib/options.js +87 -0
- package/lib/page.js +75 -0
- package/lib/text.js +36 -0
- package/lib/title.js +14 -0
- package/middleware.js +53 -0
- package/package.json +76 -0
- package/schema.d.ts +4 -0
- package/schema.js +31 -0
- package/virtual.d.ts +9 -0
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
|
+
}
|