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/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
@@ -0,0 +1,4 @@
1
+ import type { z } from 'astro/zod'
2
+
3
+ export declare const seoFields: () => z.ZodObject<any>
4
+ export declare const seoSchema: () => z.ZodObject<{ seo: z.ZodOptional<z.ZodObject<any>> }>
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
+ }