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/lib/text.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const length = (value) => [...String(value ?? '')].length
|
|
2
|
+
|
|
3
|
+
export const pick = (value, lang, locale) => {
|
|
4
|
+
if (value === undefined || value === null) return undefined
|
|
5
|
+
if (typeof value !== 'object' || Array.isArray(value)) return value
|
|
6
|
+
const short = String(lang ?? '').split('-')[0]
|
|
7
|
+
if (lang && lang in value) return value[lang]
|
|
8
|
+
if (short && short in value) return value[short]
|
|
9
|
+
if (locale && locale in value) return value[locale]
|
|
10
|
+
if ('root' in value) return value.root
|
|
11
|
+
return Object.values(value)[0]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const fill = (template, values) =>
|
|
15
|
+
String(template).replace(/\{(\w+)\}/g, (whole, key) => (key in values ? String(values[key]) : whole))
|
|
16
|
+
|
|
17
|
+
const ESCAPES = { '<': '\\u003c', '>': '\\u003e', '&': '\\u0026', '\u2028': '\\u2028', '\u2029': '\\u2029' }
|
|
18
|
+
|
|
19
|
+
export const serialize = (value) => JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (char) => ESCAPES[char])
|
|
20
|
+
|
|
21
|
+
const SPECIAL = /[.+^${}()|[\]\\]/g
|
|
22
|
+
|
|
23
|
+
export const matcher = (pattern) => {
|
|
24
|
+
const source = String(pattern)
|
|
25
|
+
.replace(SPECIAL, '\\$&')
|
|
26
|
+
.replace(/\*\*/g, '\u0000')
|
|
27
|
+
.replace(/\*/g, '[^/]*')
|
|
28
|
+
.replace(/\u0000/g, '.*')
|
|
29
|
+
.replace(/\?/g, '[^/]')
|
|
30
|
+
const expression = new RegExp(`^${source}$`)
|
|
31
|
+
return (path) => expression.test(path)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const firstMatch = (rules, path) => (rules ?? []).find((rule) => [].concat(rule.match).some((pattern) => matcher(pattern)(path)))
|
|
35
|
+
|
|
36
|
+
export const anyMatch = (patterns, path) => (patterns ?? []).some((pattern) => matcher(pattern)(path))
|
package/lib/title.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { fill, firstMatch, length, pick } from './text.js'
|
|
2
|
+
|
|
3
|
+
export function resolveTitle({ label, seoTitle, path, lang, locale, siteName, options }) {
|
|
4
|
+
const rule = seoTitle ? undefined : firstMatch(options.templates, path)
|
|
5
|
+
const template = rule ? pick(rule.template, lang, locale) : undefined
|
|
6
|
+
const base = seoTitle ?? (template ? fill(template, { title: label, site: siteName }) : label)
|
|
7
|
+
const source = seoTitle ? 'frontmatter' : template ? 'template' : 'label'
|
|
8
|
+
const branded = `${base} ${options.delimiter} ${siteName}`
|
|
9
|
+
const mentionsSite = base.toLowerCase().includes(String(siteName).toLowerCase())
|
|
10
|
+
const withBrand =
|
|
11
|
+
options.brand === 'always' ||
|
|
12
|
+
(options.brand === 'auto' && !mentionsSite && length(branded) <= options.max)
|
|
13
|
+
return { base, full: withBrand ? branded : base, source, branded: withBrand }
|
|
14
|
+
}
|
package/middleware.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { defineRouteMiddleware } from '@astrojs/starlight/route-data'
|
|
2
|
+
import options from 'virtual:starlight-seo/options'
|
|
3
|
+
import { graph as graphHook, page as pageHook } from 'virtual:starlight-seo/extend'
|
|
4
|
+
import { applyHead, canonicalOf, graphOf, pushGraph, setCanonical } from './lib/head.js'
|
|
5
|
+
import { resolvePage, retitle } from './lib/page.js'
|
|
6
|
+
import { anyMatch } from './lib/text.js'
|
|
7
|
+
|
|
8
|
+
export const onRequest = defineRouteMiddleware(async (context, next) => {
|
|
9
|
+
await next()
|
|
10
|
+
|
|
11
|
+
const route = context.locals.starlightRoute
|
|
12
|
+
const data = route?.entry?.data
|
|
13
|
+
if (!data) return
|
|
14
|
+
|
|
15
|
+
const pathname = context.url.pathname
|
|
16
|
+
const locale = route.locale
|
|
17
|
+
const prefix = locale ? `/${locale}` : ''
|
|
18
|
+
const path = prefix && pathname.startsWith(`${prefix}/`) ? pathname.slice(prefix.length) : pathname
|
|
19
|
+
if (anyMatch(options.exclude, path)) return
|
|
20
|
+
|
|
21
|
+
if (route.isFallback && options.fallback === 'canonical') {
|
|
22
|
+
setCanonical(route.head, new URL(`${options.defaultPrefix}${path}`, `${options.origin}/`).href)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const page = resolvePage(options, {
|
|
26
|
+
pathname,
|
|
27
|
+
path,
|
|
28
|
+
lang: route.lang,
|
|
29
|
+
locale,
|
|
30
|
+
homeHref: `${prefix}/`,
|
|
31
|
+
isHome: path === '/',
|
|
32
|
+
canonical: canonicalOf(route.head) ?? new URL(pathname, `${options.origin}/`).href,
|
|
33
|
+
label: data.title,
|
|
34
|
+
description: data.description,
|
|
35
|
+
template: data.template,
|
|
36
|
+
lastUpdated: route.lastUpdated,
|
|
37
|
+
siteTitle: route.siteTitle,
|
|
38
|
+
sidebar: route.sidebar,
|
|
39
|
+
seo: data.seo,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (typeof pageHook === 'function') {
|
|
43
|
+
const patch = (await pageHook(page, context)) ?? {}
|
|
44
|
+
Object.assign(page, patch)
|
|
45
|
+
if (patch.title !== undefined && patch.headTitle === undefined) Object.assign(page, retitle(options, page, patch.title))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
applyHead(route.head, page, options)
|
|
49
|
+
|
|
50
|
+
let nodes = graphOf(page, options)
|
|
51
|
+
if (typeof graphHook === 'function') nodes = (await graphHook(nodes, page, context)) ?? nodes
|
|
52
|
+
pushGraph(route.head, nodes)
|
|
53
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "starlight-seo",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Starlight plugin for search-ready titles, connected JSON-LD, complete Open Graph tags and a build-time SEO audit.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "kaktak.net",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kaktaknet/starlight-seo.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/kaktaknet/starlight-seo#readme",
|
|
13
|
+
"bugs": "https://github.com/kaktaknet/starlight-seo/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"starlight",
|
|
16
|
+
"starlight-plugin",
|
|
17
|
+
"astro",
|
|
18
|
+
"withastro",
|
|
19
|
+
"seo",
|
|
20
|
+
"json-ld",
|
|
21
|
+
"schema.org",
|
|
22
|
+
"open-graph",
|
|
23
|
+
"structured-data"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./index.d.ts",
|
|
28
|
+
"import": "./index.js"
|
|
29
|
+
},
|
|
30
|
+
"./schema": {
|
|
31
|
+
"types": "./schema.d.ts",
|
|
32
|
+
"import": "./schema.js"
|
|
33
|
+
},
|
|
34
|
+
"./middleware": "./middleware.js",
|
|
35
|
+
"./audit": {
|
|
36
|
+
"types": "./lib/audit.d.ts",
|
|
37
|
+
"import": "./lib/audit.js"
|
|
38
|
+
},
|
|
39
|
+
"./core": {
|
|
40
|
+
"types": "./core.d.ts",
|
|
41
|
+
"import": "./core.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"index.js",
|
|
46
|
+
"index.d.ts",
|
|
47
|
+
"core.js",
|
|
48
|
+
"core.d.ts",
|
|
49
|
+
"schema.js",
|
|
50
|
+
"schema.d.ts",
|
|
51
|
+
"middleware.js",
|
|
52
|
+
"virtual.d.ts",
|
|
53
|
+
"lib",
|
|
54
|
+
"CHANGELOG.md"
|
|
55
|
+
],
|
|
56
|
+
"scripts": {
|
|
57
|
+
"test": "node --test tests/*.test.js",
|
|
58
|
+
"test:fixture": "node tests/fixture.mjs"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@astrojs/starlight": ">=0.32.0",
|
|
62
|
+
"astro": ">=5.0.0"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=20"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@astrojs/starlight": "0.42.5",
|
|
69
|
+
"astro": "7.3.5",
|
|
70
|
+
"starlight-seo": "link:."
|
|
71
|
+
},
|
|
72
|
+
"packageManager": "pnpm@12.6.0",
|
|
73
|
+
"publishConfig": {
|
|
74
|
+
"access": "public"
|
|
75
|
+
}
|
|
76
|
+
}
|
package/schema.d.ts
ADDED
package/schema.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from 'astro/zod'
|
|
2
|
+
|
|
3
|
+
const thing = z.union([
|
|
4
|
+
z.string(),
|
|
5
|
+
z.object({
|
|
6
|
+
type: z.string().optional(),
|
|
7
|
+
name: z.string(),
|
|
8
|
+
description: z.string().optional(),
|
|
9
|
+
url: z.string().optional(),
|
|
10
|
+
sameAs: z.union([z.string(), z.array(z.string())]).optional(),
|
|
11
|
+
}),
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
export const seoFields = () =>
|
|
15
|
+
z
|
|
16
|
+
.object({
|
|
17
|
+
title: z.string().min(1).optional(),
|
|
18
|
+
description: z.string().min(1).optional(),
|
|
19
|
+
type: z.string().optional(),
|
|
20
|
+
image: z.string().optional(),
|
|
21
|
+
imageAlt: z.string().optional(),
|
|
22
|
+
noindex: z.boolean().optional(),
|
|
23
|
+
published: z.coerce.date().optional(),
|
|
24
|
+
modified: z.coerce.date().optional(),
|
|
25
|
+
section: z.string().optional(),
|
|
26
|
+
keywords: z.array(z.string()).optional(),
|
|
27
|
+
about: z.union([thing, z.array(thing)]).optional(),
|
|
28
|
+
})
|
|
29
|
+
.strict()
|
|
30
|
+
|
|
31
|
+
export const seoSchema = () => z.object({ seo: seoFields().optional() })
|
package/virtual.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
declare module 'virtual:starlight-seo/options' {
|
|
2
|
+
const options: Record<string, any>
|
|
3
|
+
export default options
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare module 'virtual:starlight-seo/extend' {
|
|
7
|
+
export const page: import('./index.js').SeoPageHook | undefined
|
|
8
|
+
export const graph: import('./index.js').SeoGraphHook | undefined
|
|
9
|
+
}
|