@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 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 (googleAnalyticsMeasurementId && config.components?.Head) {
324
- throw new Error('Hagilight Google Analytics cannot replace an existing Starlight Head override. Compose @hagicode/hagilight/GoogleAnalytics in your Head instead.');
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.28.1.d52968b",
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.28.1.d52968b"
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
+ }