@hagicode/hagilight-starlight 0.2.3-dev.28.1.d52968b → 0.2.3-dev.32.1.0da142b
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/SEOHead.astro +69 -0
- package/index.mjs +143 -4
- package/package.json +9 -3
- package/seo-schema.mjs +24 -0
- package/seo-utils.mjs +410 -0
package/SEOHead.astro
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from 'astro:content';
|
|
3
|
+
import {
|
|
4
|
+
buildStructuredData,
|
|
5
|
+
composeSeoHead,
|
|
6
|
+
createPublishedDocsLookup,
|
|
7
|
+
filterPublishedLocaleAlternates,
|
|
8
|
+
resolveSeoMetadata,
|
|
9
|
+
resolveSeoPage,
|
|
10
|
+
serializeJsonLd,
|
|
11
|
+
} from './seo-utils.mjs';
|
|
12
|
+
|
|
13
|
+
const { options } = Astro.props;
|
|
14
|
+
const route = Astro.locals.starlightRoute;
|
|
15
|
+
const routeId = route?.id ?? route?.entry?.id ?? '';
|
|
16
|
+
const isNotFound = /(?:^|\/)404(?:\.html)?\/?$/u.test(Astro.url.pathname)
|
|
17
|
+
|| routeId === '404'
|
|
18
|
+
|| routeId.endsWith('/404');
|
|
19
|
+
let head = route?.head ?? [];
|
|
20
|
+
let structuredData = [];
|
|
21
|
+
|
|
22
|
+
if (route?.entry?.collection === 'docs' && !isNotFound && route.entry.data?.draft !== true) {
|
|
23
|
+
const entries = await getCollection('docs', ({ data }) => data.draft !== true);
|
|
24
|
+
const lookup = createPublishedDocsLookup(entries, options.seoLocales);
|
|
25
|
+
const page = resolveSeoPage(routeId, options.seoLocales);
|
|
26
|
+
const sourceEntry = lookup.get(page.route, page.slug);
|
|
27
|
+
const explicitHead = [options.consumerHead, route.entry.data.head];
|
|
28
|
+
head = filterPublishedLocaleAlternates(head, {
|
|
29
|
+
entryId: routeId,
|
|
30
|
+
locales: options.seoLocales,
|
|
31
|
+
lookup,
|
|
32
|
+
basePath: options.seoBasePath,
|
|
33
|
+
explicitHead,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const seoMetadata = resolveSeoMetadata({
|
|
37
|
+
entry: route.entry,
|
|
38
|
+
head,
|
|
39
|
+
pageSeo: route.entry.data.seo,
|
|
40
|
+
siteSeo: options.seo,
|
|
41
|
+
site: options.seoSite,
|
|
42
|
+
basePath: options.seoBasePath,
|
|
43
|
+
explicitHead,
|
|
44
|
+
body: sourceEntry?.body,
|
|
45
|
+
});
|
|
46
|
+
head = composeSeoHead(head, seoMetadata, explicitHead);
|
|
47
|
+
const canonicalUrl = head.find(({ tag, attrs }) => tag === 'link' && attrs?.rel === 'canonical')?.attrs.href;
|
|
48
|
+
structuredData = buildStructuredData({
|
|
49
|
+
entry: route.entry,
|
|
50
|
+
entryId: routeId,
|
|
51
|
+
canonicalUrl,
|
|
52
|
+
site: options.seoSite,
|
|
53
|
+
siteTitle: route.siteTitle,
|
|
54
|
+
pageSeo: { ...route.entry.data.seo, description: seoMetadata.description },
|
|
55
|
+
organization: options.seo.organization,
|
|
56
|
+
locales: options.seoLocales,
|
|
57
|
+
defaultLocale: options.seoDefaultLocale,
|
|
58
|
+
lookup,
|
|
59
|
+
basePath: options.seoBasePath,
|
|
60
|
+
format: options.seoFormat,
|
|
61
|
+
trailingSlash: options.seoTrailingSlash,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
{head.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)}
|
|
67
|
+
{structuredData.map((value) => (
|
|
68
|
+
<script type="application/ld+json" set:html={serializeJsonLd(value)} />
|
|
69
|
+
))}
|
package/index.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { resolveRssLocales, resolveRssOptions } from './rss-utils.mjs';
|
|
4
|
+
import {
|
|
5
|
+
isValidSeoImageReference,
|
|
6
|
+
resolveSeoImageUrl,
|
|
7
|
+
resolveSeoLocales,
|
|
8
|
+
} from './seo-utils.mjs';
|
|
4
9
|
|
|
5
10
|
const GOOGLE_ID_PATTERN = /^G-[A-Z0-9]+$/u;
|
|
6
11
|
const FIFTY_ONE_LA_ID_PATTERN = /^[A-Za-z0-9_-]+$/u;
|
|
@@ -88,6 +93,97 @@ function getRssFeedUrl(config) {
|
|
|
88
93
|
return rssLink?.attrs.href;
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
function optionalSeoText(value, field) {
|
|
97
|
+
if (value === undefined) return undefined;
|
|
98
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
99
|
+
throw new TypeError(`Hagilight SEO ${field} must be a non-empty string.`);
|
|
100
|
+
}
|
|
101
|
+
return value.trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveSeoOptions(config) {
|
|
105
|
+
if (config !== undefined && (!config || typeof config !== 'object' || Array.isArray(config))) {
|
|
106
|
+
throw new TypeError('Hagilight seo options must be an object.');
|
|
107
|
+
}
|
|
108
|
+
if (config?.enabled !== undefined && typeof config.enabled !== 'boolean') {
|
|
109
|
+
throw new TypeError('Hagilight seo enabled option must be a boolean.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const image = config?.image;
|
|
113
|
+
if (image !== undefined && !isValidSeoImageReference(image)) {
|
|
114
|
+
throw new TypeError('Hagilight SEO image must be an absolute HTTP(S) URL or a site-root path.');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let organization;
|
|
118
|
+
if (config?.organization !== undefined) {
|
|
119
|
+
const identity = config.organization;
|
|
120
|
+
if (!identity || typeof identity !== 'object' || Array.isArray(identity)) {
|
|
121
|
+
throw new TypeError('Hagilight SEO organization option must be an object.');
|
|
122
|
+
}
|
|
123
|
+
const name = optionalSeoText(identity.name, 'organization name');
|
|
124
|
+
const url = optionalSeoText(identity.url, 'organization URL');
|
|
125
|
+
if (!name) throw new TypeError('Hagilight SEO organization name is required.');
|
|
126
|
+
if (!url) throw new TypeError('Hagilight SEO organization URL is required.');
|
|
127
|
+
let parsedUrl;
|
|
128
|
+
try {
|
|
129
|
+
parsedUrl = new URL(url);
|
|
130
|
+
} catch {
|
|
131
|
+
throw new TypeError('Hagilight SEO organization URL must be an absolute HTTP(S) URL.');
|
|
132
|
+
}
|
|
133
|
+
if (!['http:', 'https:'].includes(parsedUrl.protocol) || parsedUrl.username || parsedUrl.password) {
|
|
134
|
+
throw new TypeError('Hagilight SEO organization URL must be an absolute HTTP(S) URL.');
|
|
135
|
+
}
|
|
136
|
+
if (identity.logo !== undefined && !isValidSeoImageReference(identity.logo)) {
|
|
137
|
+
throw new TypeError('Hagilight SEO organization logo must be an absolute HTTP(S) URL or a site-root path.');
|
|
138
|
+
}
|
|
139
|
+
organization = {
|
|
140
|
+
name,
|
|
141
|
+
url: parsedUrl.href,
|
|
142
|
+
...(identity.logo === undefined ? {} : { logo: identity.logo }),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
enabled: config?.enabled ?? true,
|
|
148
|
+
title: optionalSeoText(config?.title, 'title'),
|
|
149
|
+
description: optionalSeoText(config?.description, 'description'),
|
|
150
|
+
...(image === undefined ? {} : { image }),
|
|
151
|
+
...(organization === undefined ? {} : { organization }),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function resolveAstroSite(site) {
|
|
156
|
+
if (site === undefined || site === null || site === '') {
|
|
157
|
+
throw new Error('Hagilight SEO requires an absolute Astro site URL. Set site or disable generated SEO with seo: { enabled: false }.');
|
|
158
|
+
}
|
|
159
|
+
let parsed;
|
|
160
|
+
try {
|
|
161
|
+
parsed = new URL(site);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new Error('Hagilight SEO requires Astro site to be an absolute HTTP(S) URL.');
|
|
164
|
+
}
|
|
165
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
|
166
|
+
throw new Error('Hagilight SEO requires Astro site to be an absolute HTTP(S) URL.');
|
|
167
|
+
}
|
|
168
|
+
return parsed.href;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolveDefaultSeoLocale(defaultLocale, locales) {
|
|
172
|
+
const configured = typeof defaultLocale === 'object' && defaultLocale !== null
|
|
173
|
+
? defaultLocale.locale ?? defaultLocale.lang
|
|
174
|
+
: defaultLocale;
|
|
175
|
+
if (configured === undefined) {
|
|
176
|
+
return locales.find(({ route }) => route === 'root')?.route ?? locales[0].route;
|
|
177
|
+
}
|
|
178
|
+
const found = locales.find(({ route, lang }) =>
|
|
179
|
+
route === configured || lang.toLowerCase() === String(configured).toLowerCase(),
|
|
180
|
+
);
|
|
181
|
+
if (!found) {
|
|
182
|
+
throw new Error(`Hagilight SEO default locale "${configured}" is not present in Starlight locales.`);
|
|
183
|
+
}
|
|
184
|
+
return found.route;
|
|
185
|
+
}
|
|
186
|
+
|
|
91
187
|
function createConfiguredIntegration(
|
|
92
188
|
instanceId,
|
|
93
189
|
serializedOptions,
|
|
@@ -105,6 +201,7 @@ function createConfiguredIntegration(
|
|
|
105
201
|
const footerPath = fileURLToPath(new URL('./Footer.astro', import.meta.url));
|
|
106
202
|
const pageTitlePath = fileURLToPath(new URL('./PageTitle.astro', import.meta.url));
|
|
107
203
|
const markdownContentPath = fileURLToPath(new URL('./MarkdownContent.astro', import.meta.url));
|
|
204
|
+
const seoHeadPath = fileURLToPath(new URL('./SEOHead.astro', import.meta.url));
|
|
108
205
|
const headerSource = headerPath
|
|
109
206
|
? `---
|
|
110
207
|
import Header from ${JSON.stringify(headerPath)};
|
|
@@ -158,12 +255,13 @@ import options from '${optionsId}';
|
|
|
158
255
|
`;
|
|
159
256
|
const headSource = `---
|
|
160
257
|
import DefaultHead from '@astrojs/starlight/components/Head.astro';
|
|
258
|
+
import SEOHead from ${JSON.stringify(seoHeadPath)};
|
|
161
259
|
import GoogleAnalytics from '@hagicode/hagilight/GoogleAnalytics';
|
|
162
260
|
import options from '${optionsId}';
|
|
163
261
|
const isNotFound = /(?:^|\\/)404(?:\\.html)?\\/?$/u.test(Astro.url.pathname);
|
|
164
262
|
---
|
|
165
263
|
|
|
166
|
-
<DefaultHead />
|
|
264
|
+
{options.seoEnabled ? <SEOHead options={options} /> : <DefaultHead />}
|
|
167
265
|
{!isNotFound && options.googleAnalyticsMeasurementId && (
|
|
168
266
|
<GoogleAnalytics measurementId={options.googleAnalyticsMeasurementId} />
|
|
169
267
|
)}
|
|
@@ -247,6 +345,7 @@ export default function hagilight(options = {}) {
|
|
|
247
345
|
&& (!options.analytics || typeof options.analytics !== 'object' || Array.isArray(options.analytics))) {
|
|
248
346
|
throw new TypeError('Hagilight analytics options must be an object.');
|
|
249
347
|
}
|
|
348
|
+
const seo = resolveSeoOptions(options.seo);
|
|
250
349
|
if (options.contentComponents !== undefined
|
|
251
350
|
&& (!options.contentComponents || typeof options.contentComponents !== 'object' || Array.isArray(options.contentComponents))) {
|
|
252
351
|
throw new TypeError('Hagilight contentComponents options must be an object.');
|
|
@@ -288,7 +387,7 @@ export default function hagilight(options = {}) {
|
|
|
288
387
|
markdownContent: markdownContentEnabled
|
|
289
388
|
? `virtual:hagilight-starlight/${instanceId}/MarkdownContent.astro`
|
|
290
389
|
: undefined,
|
|
291
|
-
head: googleAnalyticsMeasurementId
|
|
390
|
+
head: seo.enabled || googleAnalyticsMeasurementId
|
|
292
391
|
? `virtual:hagilight-starlight/${instanceId}/Head.astro`
|
|
293
392
|
: undefined,
|
|
294
393
|
};
|
|
@@ -299,6 +398,8 @@ export default function hagilight(options = {}) {
|
|
|
299
398
|
googleAnalyticsMeasurementId,
|
|
300
399
|
fiftyOneLaId,
|
|
301
400
|
aiDisclosures,
|
|
401
|
+
seoEnabled: seo.enabled,
|
|
402
|
+
seo,
|
|
302
403
|
};
|
|
303
404
|
|
|
304
405
|
return {
|
|
@@ -320,9 +421,47 @@ export default function hagilight(options = {}) {
|
|
|
320
421
|
if (markdownContentEnabled && config.components?.MarkdownContent) {
|
|
321
422
|
throw new Error('Hagilight cannot replace an existing Starlight MarkdownContent override. Set contentComponents: { markdownContent: false } and compose @hagicode/hagilight-starlight/MarkdownContent into your MarkdownContent component.');
|
|
322
423
|
}
|
|
323
|
-
if (
|
|
324
|
-
|
|
424
|
+
if (componentIds.head && config.components?.Head) {
|
|
425
|
+
if (seo.enabled) {
|
|
426
|
+
throw new Error('Hagilight SEO cannot replace an existing Starlight Head override. Set seo: { enabled: false } and disable Google Analytics to keep your Head, or compose your site metadata explicitly.');
|
|
427
|
+
}
|
|
428
|
+
throw new Error('Hagilight Google Analytics cannot replace an existing Starlight Head override. Disable Google Analytics or compose @hagicode/hagilight/GoogleAnalytics in your Head instead.');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
let seoSite;
|
|
432
|
+
let seoLocales = [];
|
|
433
|
+
let defaultSeoLocale = 'root';
|
|
434
|
+
if (seo.enabled) {
|
|
435
|
+
seoSite = resolveAstroSite(astroConfig.site);
|
|
436
|
+
seoLocales = resolveSeoLocales(config.locales);
|
|
437
|
+
defaultSeoLocale = resolveDefaultSeoLocale(config.defaultLocale, seoLocales);
|
|
325
438
|
}
|
|
439
|
+
const basePath = astroConfig.base ?? '/';
|
|
440
|
+
serializedOptions.seoSite = seoSite;
|
|
441
|
+
serializedOptions.seoBasePath = basePath;
|
|
442
|
+
serializedOptions.seoLocales = seoLocales;
|
|
443
|
+
serializedOptions.seoDefaultLocale = defaultSeoLocale;
|
|
444
|
+
serializedOptions.seoFormat = astroConfig.build?.format ?? 'directory';
|
|
445
|
+
serializedOptions.seoTrailingSlash = astroConfig.trailingSlash ?? 'ignore';
|
|
446
|
+
serializedOptions.seo = seo.enabled
|
|
447
|
+
? {
|
|
448
|
+
...seo,
|
|
449
|
+
...(seo.image === undefined
|
|
450
|
+
? {}
|
|
451
|
+
: { image: resolveSeoImageUrl(seo.image, { site: seoSite, basePath }) }),
|
|
452
|
+
...(seo.organization === undefined
|
|
453
|
+
? {}
|
|
454
|
+
: {
|
|
455
|
+
organization: {
|
|
456
|
+
...seo.organization,
|
|
457
|
+
...(seo.organization.logo === undefined
|
|
458
|
+
? {}
|
|
459
|
+
: { logo: resolveSeoImageUrl(seo.organization.logo, { site: seoSite, basePath }) }),
|
|
460
|
+
},
|
|
461
|
+
}),
|
|
462
|
+
}
|
|
463
|
+
: seo;
|
|
464
|
+
serializedOptions.consumerHead = config.head ?? [];
|
|
326
465
|
|
|
327
466
|
const generateRss = options.rss?.enabled !== false && !getRssFeedUrl(config);
|
|
328
467
|
if (generateRss && !astroConfig.site) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hagicode/hagilight-starlight",
|
|
3
|
-
"version": "0.2.3-dev.
|
|
3
|
+
"version": "0.2.3-dev.32.1.0da142b",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Starlight customizations for HagiCode documentation",
|
|
6
6
|
"repository": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"./article-promotion-schema": "./article-promotion-schema.mjs",
|
|
19
19
|
"./ai-disclosure-schema": "./ai-disclosure-schema.mjs",
|
|
20
20
|
"./rss-schema": "./rss-schema.mjs",
|
|
21
|
+
"./seo-schema": "./seo-schema.mjs",
|
|
21
22
|
"./ContentLayoutToggle": "./ContentLayoutToggle.astro",
|
|
22
23
|
"./Header": "./Header.astro",
|
|
23
24
|
"./Footer": "./Footer.astro",
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
],
|
|
38
39
|
"dependencies": {
|
|
39
40
|
"@astrojs/rss": "^4.0.19",
|
|
40
|
-
"@hagicode/hagilight": "0.2.3-dev.
|
|
41
|
+
"@hagicode/hagilight": "0.2.3-dev.32.1.0da142b"
|
|
41
42
|
},
|
|
42
43
|
"peerDependencies": {
|
|
43
44
|
"@astrojs/starlight": "^0.42.4",
|
|
@@ -45,6 +46,11 @@
|
|
|
45
46
|
},
|
|
46
47
|
"keywords": [
|
|
47
48
|
"astro-component",
|
|
48
|
-
"withastro"
|
|
49
|
+
"withastro",
|
|
50
|
+
"astro-seo",
|
|
51
|
+
"starlight",
|
|
52
|
+
"multilingual-documentation",
|
|
53
|
+
"hreflang",
|
|
54
|
+
"structured-data"
|
|
49
55
|
]
|
|
50
56
|
}
|
package/seo-schema.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'astro/zod';
|
|
2
|
+
import { isValidSeoImageReference } from './seo-utils.mjs';
|
|
3
|
+
|
|
4
|
+
const optionalText = z.string().trim().min(1).optional();
|
|
5
|
+
const optionalImage = z.string()
|
|
6
|
+
.trim()
|
|
7
|
+
.min(1)
|
|
8
|
+
.refine(isValidSeoImageReference, {
|
|
9
|
+
message: 'Use an absolute HTTP(S) URL or a site-root path beginning with "/".',
|
|
10
|
+
})
|
|
11
|
+
.optional();
|
|
12
|
+
|
|
13
|
+
export const seoSchema = z.object({
|
|
14
|
+
seo: z.object({
|
|
15
|
+
title: optionalText,
|
|
16
|
+
description: optionalText,
|
|
17
|
+
image: optionalImage,
|
|
18
|
+
author: optionalText,
|
|
19
|
+
publishedDate: z.coerce.date().optional(),
|
|
20
|
+
}).optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export { seoSchema as seoFrontmatterSchema };
|
|
24
|
+
export default seoSchema;
|
package/seo-utils.mjs
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { isBlogEntry } from './rss-utils.mjs';
|
|
2
|
+
|
|
3
|
+
const normalizeSlug = (slug) => slug
|
|
4
|
+
.replace(/^\/+|\/+$/gu, '')
|
|
5
|
+
.replace(/(?:^|\/)index$/u, '');
|
|
6
|
+
const publishedLookupCache = new WeakMap();
|
|
7
|
+
|
|
8
|
+
export function resolveSeoLocales(locales) {
|
|
9
|
+
const entries = locales === undefined
|
|
10
|
+
? [['root', { lang: 'en' }]]
|
|
11
|
+
: Object.entries(locales);
|
|
12
|
+
const usedLanguages = new Map();
|
|
13
|
+
const resolved = entries.map(([route, config]) => {
|
|
14
|
+
const lang = typeof config === 'string'
|
|
15
|
+
? config
|
|
16
|
+
: config?.lang ?? (route === 'root' ? undefined : route);
|
|
17
|
+
if (typeof lang !== 'string' || !lang.trim()) {
|
|
18
|
+
throw new TypeError(`Hagilight SEO locale "${route}" must have a language tag.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let normalizedLang;
|
|
22
|
+
try {
|
|
23
|
+
[normalizedLang] = Intl.getCanonicalLocales(lang);
|
|
24
|
+
} catch {
|
|
25
|
+
throw new TypeError(`Hagilight SEO locale "${route}" has an invalid language tag "${lang}".`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const collisionKey = normalizedLang.toLowerCase();
|
|
29
|
+
const existingRoute = usedLanguages.get(collisionKey);
|
|
30
|
+
if (existingRoute) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Hagilight SEO locales "${existingRoute}" and "${route}" use the same language tag "${normalizedLang}".`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
usedLanguages.set(collisionKey, route);
|
|
36
|
+
return { route, lang: normalizedLang };
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (resolved.length === 0) {
|
|
40
|
+
throw new Error('Hagilight SEO requires at least one configured Starlight locale.');
|
|
41
|
+
}
|
|
42
|
+
return resolved;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveSeoPage(entryId, locales) {
|
|
46
|
+
const id = normalizeSlug(entryId ?? '');
|
|
47
|
+
const localizedRoutes = locales
|
|
48
|
+
.filter(({ route }) => route !== 'root')
|
|
49
|
+
.sort((left, right) => right.route.length - left.route.length);
|
|
50
|
+
const localized = localizedRoutes.find(({ route }) => id === route || id.startsWith(`${route}/`));
|
|
51
|
+
if (localized) {
|
|
52
|
+
return {
|
|
53
|
+
route: localized.route,
|
|
54
|
+
slug: normalizeSlug(id.slice(localized.route.length)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { route: 'root', slug: id };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createPublishedDocsLookup(entries, locales) {
|
|
61
|
+
const localeKey = JSON.stringify(locales);
|
|
62
|
+
let lookups = publishedLookupCache.get(entries);
|
|
63
|
+
if (lookups?.has(localeKey)) return lookups.get(localeKey);
|
|
64
|
+
|
|
65
|
+
const pages = new Map();
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (!entry || entry.data?.draft === true) continue;
|
|
68
|
+
const page = resolveSeoPage(entry.id, locales);
|
|
69
|
+
pages.set(`${page.route}\u0000${page.slug}`, entry);
|
|
70
|
+
}
|
|
71
|
+
const lookup = {
|
|
72
|
+
get(route, slug) {
|
|
73
|
+
return pages.get(`${route}\u0000${normalizeSlug(slug)}`);
|
|
74
|
+
},
|
|
75
|
+
has(route, slug) {
|
|
76
|
+
return pages.has(`${route}\u0000${normalizeSlug(slug)}`);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
if (!lookups) {
|
|
80
|
+
lookups = new Map();
|
|
81
|
+
publishedLookupCache.set(entries, lookups);
|
|
82
|
+
}
|
|
83
|
+
lookups.set(localeKey, lookup);
|
|
84
|
+
return lookup;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseCandidatePage(href, { locales, basePath }) {
|
|
88
|
+
let pathname;
|
|
89
|
+
try {
|
|
90
|
+
pathname = new URL(href).pathname;
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const base = `/${(basePath ?? '/').replace(/^\/+|\/+$/gu, '')}`;
|
|
96
|
+
if (base !== '/' && pathname !== base && !pathname.startsWith(`${base}/`)) return undefined;
|
|
97
|
+
if (base !== '/') pathname = pathname.slice(base.length) || '/';
|
|
98
|
+
|
|
99
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
100
|
+
if (segments.at(-1) === 'index.html') segments.pop();
|
|
101
|
+
else if (segments.at(-1)?.endsWith('.html')) {
|
|
102
|
+
segments[segments.length - 1] = segments.at(-1).slice(0, -5);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const routes = locales
|
|
106
|
+
.filter(({ route }) => route !== 'root')
|
|
107
|
+
.sort((left, right) => right.route.length - left.route.length);
|
|
108
|
+
let route = 'root';
|
|
109
|
+
if (segments[0]?.endsWith('.html')) {
|
|
110
|
+
const first = segments[0].slice(0, -5);
|
|
111
|
+
const matched = routes.find((locale) => locale.route === first);
|
|
112
|
+
if (matched) {
|
|
113
|
+
route = matched.route;
|
|
114
|
+
segments.shift();
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
const matched = routes.find(({ route: localeRoute }) => segments[0] === localeRoute);
|
|
118
|
+
if (matched) {
|
|
119
|
+
route = matched.route;
|
|
120
|
+
segments.shift();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { route, slug: normalizeSlug(segments.join('/')) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isAlternateLink(entry) {
|
|
128
|
+
return entry?.tag === 'link'
|
|
129
|
+
&& entry.attrs?.rel === 'alternate'
|
|
130
|
+
&& typeof entry.attrs.hreflang === 'string'
|
|
131
|
+
&& typeof entry.attrs.href === 'string';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function explicitAlternateKeys(heads) {
|
|
135
|
+
return new Set(heads.flatMap((head) => (Array.isArray(head) ? head : [])
|
|
136
|
+
.filter(isAlternateLink)
|
|
137
|
+
.map(({ attrs }) => `${attrs.hreflang.toLowerCase()}\u0000${attrs.href}`)));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function filterPublishedLocaleAlternates(head, {
|
|
141
|
+
entryId,
|
|
142
|
+
locales,
|
|
143
|
+
lookup,
|
|
144
|
+
basePath = '/',
|
|
145
|
+
explicitHead = [],
|
|
146
|
+
}) {
|
|
147
|
+
const page = resolveSeoPage(entryId, locales);
|
|
148
|
+
const explicit = explicitAlternateKeys(explicitHead);
|
|
149
|
+
const routesByLanguage = new Map(locales.map(({ route, lang }) => [lang.toLowerCase(), route]));
|
|
150
|
+
|
|
151
|
+
return head.filter((entry) => {
|
|
152
|
+
if (!isAlternateLink(entry)) return true;
|
|
153
|
+
const { href, hreflang } = entry.attrs;
|
|
154
|
+
if (explicit.has(`${hreflang.toLowerCase()}\u0000${href}`)) return true;
|
|
155
|
+
|
|
156
|
+
const candidate = parseCandidatePage(href, { locales, basePath });
|
|
157
|
+
if (!candidate || candidate.slug !== page.slug) return true;
|
|
158
|
+
if (hreflang.toLowerCase() === 'x-default') {
|
|
159
|
+
return lookup.has(candidate.route, candidate.slug);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const configuredRoute = routesByLanguage.get(hreflang.toLowerCase());
|
|
163
|
+
if (!configuredRoute || configuredRoute !== candidate.route) return true;
|
|
164
|
+
return lookup.has(configuredRoute, candidate.slug);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeBasePath(basePath = '/') {
|
|
169
|
+
const normalized = `/${basePath.replace(/^\/+|\/+$/gu, '')}`;
|
|
170
|
+
return normalized === '/' ? '' : normalized;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function buildDocsPageUrl(route, slug, {
|
|
174
|
+
site,
|
|
175
|
+
basePath = '/',
|
|
176
|
+
format = 'directory',
|
|
177
|
+
trailingSlash = 'always',
|
|
178
|
+
}) {
|
|
179
|
+
const id = [route === 'root' ? '' : route, normalizeSlug(slug)].filter(Boolean).join('/');
|
|
180
|
+
let pagePath;
|
|
181
|
+
if (format === 'file') {
|
|
182
|
+
pagePath = `/${id ? `${id}.html` : 'index.html'}`;
|
|
183
|
+
} else if (!id) {
|
|
184
|
+
pagePath = '/';
|
|
185
|
+
} else {
|
|
186
|
+
pagePath = `/${id}${trailingSlash === 'never' ? '' : '/'}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const base = normalizeBasePath(basePath);
|
|
190
|
+
return new URL(`${base}${pagePath}`, site).href;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validDate(value) {
|
|
194
|
+
if (value === undefined || value === null || value === false) return undefined;
|
|
195
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
196
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function buildStructuredData({
|
|
200
|
+
entry,
|
|
201
|
+
entryId,
|
|
202
|
+
canonicalUrl,
|
|
203
|
+
site,
|
|
204
|
+
siteTitle,
|
|
205
|
+
pageSeo = {},
|
|
206
|
+
organization,
|
|
207
|
+
locales,
|
|
208
|
+
defaultLocale = 'root',
|
|
209
|
+
lookup,
|
|
210
|
+
basePath = '/',
|
|
211
|
+
format = 'directory',
|
|
212
|
+
trailingSlash = 'always',
|
|
213
|
+
}) {
|
|
214
|
+
if (!entry || entry.data?.draft === true || !canonicalUrl) return [];
|
|
215
|
+
const page = resolveSeoPage(entryId, locales);
|
|
216
|
+
if (!lookup.has(page.route, page.slug)) return [];
|
|
217
|
+
|
|
218
|
+
const data = entry.data ?? {};
|
|
219
|
+
const structuredData = [];
|
|
220
|
+
if (isBlogEntry(page.slug)) {
|
|
221
|
+
const article = {
|
|
222
|
+
'@context': 'https://schema.org',
|
|
223
|
+
'@type': 'Article',
|
|
224
|
+
headline: data.title,
|
|
225
|
+
url: canonicalUrl,
|
|
226
|
+
mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
|
|
227
|
+
};
|
|
228
|
+
const description = pageSeo.description ?? data.description;
|
|
229
|
+
if (description) article.description = description;
|
|
230
|
+
const published = validDate(pageSeo.publishedDate ?? data.publishedDate);
|
|
231
|
+
const modified = validDate(data.lastUpdated);
|
|
232
|
+
if (published) article.datePublished = published;
|
|
233
|
+
if (modified) article.dateModified = modified;
|
|
234
|
+
const author = pageSeo.author ?? data.author;
|
|
235
|
+
if (typeof author === 'string' && author.trim()) {
|
|
236
|
+
article.author = { '@type': 'Person', name: author.trim() };
|
|
237
|
+
}
|
|
238
|
+
structuredData.push(article);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const slugParts = page.slug.split('/').filter(Boolean);
|
|
242
|
+
const crumbs = [];
|
|
243
|
+
for (let index = 0; index <= slugParts.length; index += 1) {
|
|
244
|
+
const ancestorSlug = slugParts.slice(0, index).join('/');
|
|
245
|
+
const ancestor = lookup.get(page.route, ancestorSlug);
|
|
246
|
+
if (!ancestor) continue;
|
|
247
|
+
const url = ancestorSlug === page.slug
|
|
248
|
+
? canonicalUrl
|
|
249
|
+
: buildDocsPageUrl(page.route, ancestorSlug, {
|
|
250
|
+
site,
|
|
251
|
+
basePath,
|
|
252
|
+
format,
|
|
253
|
+
trailingSlash,
|
|
254
|
+
});
|
|
255
|
+
crumbs.push({
|
|
256
|
+
'@type': 'ListItem',
|
|
257
|
+
position: crumbs.length + 1,
|
|
258
|
+
name: ancestor.data.title,
|
|
259
|
+
item: url,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (crumbs.length > 0) {
|
|
263
|
+
structuredData.push({
|
|
264
|
+
'@context': 'https://schema.org',
|
|
265
|
+
'@type': 'BreadcrumbList',
|
|
266
|
+
itemListElement: crumbs,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (organization && page.route === defaultLocale && page.slug === '') {
|
|
271
|
+
const org = {
|
|
272
|
+
'@context': 'https://schema.org',
|
|
273
|
+
'@type': 'Organization',
|
|
274
|
+
name: organization.name,
|
|
275
|
+
url: organization.url,
|
|
276
|
+
};
|
|
277
|
+
if (organization.logo) org.logo = organization.logo;
|
|
278
|
+
if (siteTitle) org.alternateName = siteTitle;
|
|
279
|
+
structuredData.push(org);
|
|
280
|
+
}
|
|
281
|
+
return structuredData;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function serializeJsonLd(value) {
|
|
285
|
+
return JSON.stringify(value).replace(/</gu, '\\u003c');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function metaKey(entry) {
|
|
289
|
+
if (entry?.tag !== 'meta') return undefined;
|
|
290
|
+
const attrs = entry.attrs ?? {};
|
|
291
|
+
if (typeof attrs.property === 'string') return `property:${attrs.property.toLowerCase()}`;
|
|
292
|
+
if (typeof attrs.name === 'string') return `name:${attrs.name.toLowerCase()}`;
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function existingMetaValue(head, key) {
|
|
297
|
+
return head.find((entry) => metaKey(entry) === key)?.attrs?.content;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function explicitMetaValue(heads, key) {
|
|
301
|
+
return heads.flatMap((head) => (Array.isArray(head) ? head : []))
|
|
302
|
+
.find((entry) => metaKey(entry) === key)?.attrs?.content;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function extractSeoDescription(body) {
|
|
306
|
+
if (typeof body !== 'string' || !body.trim()) return undefined;
|
|
307
|
+
const text = body
|
|
308
|
+
.replace(/^```[\s\S]*?^```/gmu, ' ')
|
|
309
|
+
.replace(/^import\s.+;?\s*$/gmu, ' ')
|
|
310
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, '$1')
|
|
311
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
|
|
312
|
+
.replace(/<[^>]*>/gu, ' ')
|
|
313
|
+
.replace(/`([^`]+)`/gu, '$1')
|
|
314
|
+
.replace(/^\s{0,3}(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+\.\s+)/gmu, '')
|
|
315
|
+
.replace(/[*_~]/gu, '')
|
|
316
|
+
.replace(/\s+/gu, ' ')
|
|
317
|
+
.trim();
|
|
318
|
+
if (!text) return undefined;
|
|
319
|
+
|
|
320
|
+
const characters = Array.from(text);
|
|
321
|
+
if (characters.length <= 160) return text;
|
|
322
|
+
let end = 159;
|
|
323
|
+
const boundary = characters.slice(0, end).lastIndexOf(' ');
|
|
324
|
+
if (boundary > 120) end = boundary;
|
|
325
|
+
return `${characters.slice(0, end).join('').trimEnd()}…`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function resolveSeoMetadata({
|
|
329
|
+
entry,
|
|
330
|
+
head,
|
|
331
|
+
pageSeo = {},
|
|
332
|
+
siteSeo = {},
|
|
333
|
+
site,
|
|
334
|
+
basePath = '/',
|
|
335
|
+
explicitHead = [],
|
|
336
|
+
body,
|
|
337
|
+
}) {
|
|
338
|
+
const title = pageSeo.title
|
|
339
|
+
?? siteSeo.title
|
|
340
|
+
?? entry.data.title
|
|
341
|
+
?? existingMetaValue(head, 'property:og:title');
|
|
342
|
+
const description = pageSeo.description
|
|
343
|
+
?? entry.data.description
|
|
344
|
+
?? explicitMetaValue(explicitHead, 'name:description')
|
|
345
|
+
?? explicitMetaValue(explicitHead, 'property:og:description')
|
|
346
|
+
?? extractSeoDescription(body)
|
|
347
|
+
?? existingMetaValue(head, 'name:description')
|
|
348
|
+
?? existingMetaValue(head, 'property:og:description')
|
|
349
|
+
?? siteSeo.description;
|
|
350
|
+
const image = pageSeo.image ?? siteSeo.image;
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
title,
|
|
354
|
+
description,
|
|
355
|
+
image: image === undefined ? undefined : resolveSeoImageUrl(image, { site, basePath }),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function isValidSeoImageReference(value) {
|
|
360
|
+
if (typeof value !== 'string' || value.trim() !== value || value.length === 0) return false;
|
|
361
|
+
if (value.startsWith('/')) return !value.startsWith('//');
|
|
362
|
+
try {
|
|
363
|
+
const url = new URL(value);
|
|
364
|
+
return (url.protocol === 'https:' || url.protocol === 'http:') && !url.username && !url.password;
|
|
365
|
+
} catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function resolveSeoImageUrl(value, { site, basePath = '/' }) {
|
|
371
|
+
if (!isValidSeoImageReference(value)) {
|
|
372
|
+
throw new TypeError('Hagilight SEO image must be an absolute HTTP(S) URL or a site-root path.');
|
|
373
|
+
}
|
|
374
|
+
if (/^https?:\/\//iu.test(value)) return new URL(value).href;
|
|
375
|
+
const base = `${normalizeBasePath(basePath)}/`;
|
|
376
|
+
return new URL(`${base}${value.replace(/^\/+/u, '')}`, site).href;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function explicitMetaKeys(heads) {
|
|
380
|
+
return new Set(heads.flatMap((head) => (Array.isArray(head) ? head : [])
|
|
381
|
+
.map(metaKey)
|
|
382
|
+
.filter(Boolean)));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function composeSeoHead(head, metadata, explicitHead = []) {
|
|
386
|
+
const explicit = explicitMetaKeys(explicitHead);
|
|
387
|
+
const values = [
|
|
388
|
+
['property:og:title', metadata.title],
|
|
389
|
+
['property:og:description', metadata.description],
|
|
390
|
+
['property:og:image', metadata.image],
|
|
391
|
+
['name:twitter:title', metadata.title],
|
|
392
|
+
['name:twitter:description', metadata.description],
|
|
393
|
+
['name:twitter:image', metadata.image],
|
|
394
|
+
];
|
|
395
|
+
const generatedKeys = new Set(values.map(([key]) => key));
|
|
396
|
+
const result = head.filter((entry) => {
|
|
397
|
+
const key = metaKey(entry);
|
|
398
|
+
return !key || explicit.has(key) || !generatedKeys.has(key);
|
|
399
|
+
});
|
|
400
|
+
const present = new Set(result.map(metaKey).filter(Boolean));
|
|
401
|
+
|
|
402
|
+
for (const [key, content] of values) {
|
|
403
|
+
if (content === undefined || explicit.has(key) || present.has(key)) continue;
|
|
404
|
+
const separator = key.indexOf(':');
|
|
405
|
+
const attribute = key.slice(0, separator);
|
|
406
|
+
const value = key.slice(separator + 1);
|
|
407
|
+
result.push({ tag: 'meta', attrs: { [attribute]: value, content } });
|
|
408
|
+
}
|
|
409
|
+
return result;
|
|
410
|
+
}
|