@hagicode/hagilight 0.2.4 → 0.2.5-dev.43.1.d86178d
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 +59 -0
- package/package.json +12 -2
- package/rss-renderer.mjs +114 -0
- package/seo-schema.d.ts +21 -0
- package/seo-schema.mjs +24 -0
- package/seo-utils.mjs +264 -0
package/SEOHead.astro
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
import {
|
|
3
|
+
composeCoreSeoHead,
|
|
4
|
+
resolveSeoMetadata,
|
|
5
|
+
serializeJsonLd,
|
|
6
|
+
} from './seo-utils.mjs';
|
|
7
|
+
|
|
8
|
+
interface HeadEntry {
|
|
9
|
+
tag: string;
|
|
10
|
+
attrs?: Record<string, string | boolean | number | undefined>;
|
|
11
|
+
content?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
page?: {
|
|
16
|
+
url?: string | URL;
|
|
17
|
+
title?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
image?: string;
|
|
20
|
+
};
|
|
21
|
+
site?: string | URL;
|
|
22
|
+
siteSeo?: {
|
|
23
|
+
title?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
image?: string;
|
|
26
|
+
};
|
|
27
|
+
basePath?: string;
|
|
28
|
+
head?: HeadEntry[];
|
|
29
|
+
structuredData?: Record<string, unknown> | Record<string, unknown>[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const {
|
|
33
|
+
page = {},
|
|
34
|
+
site: configuredSite = Astro.site,
|
|
35
|
+
siteSeo = {},
|
|
36
|
+
basePath = import.meta.env.BASE_URL,
|
|
37
|
+
head = [],
|
|
38
|
+
structuredData = [],
|
|
39
|
+
} = Astro.props;
|
|
40
|
+
const site = configuredSite?.toString();
|
|
41
|
+
const currentPageUrl = site
|
|
42
|
+
? new URL(`${Astro.url.pathname}${Astro.url.search}${Astro.url.hash}`, site).href
|
|
43
|
+
: undefined;
|
|
44
|
+
const metadata = {
|
|
45
|
+
...resolveSeoMetadata({ pageSeo: page, siteSeo, site, basePath }),
|
|
46
|
+
site,
|
|
47
|
+
};
|
|
48
|
+
const canonicalUrl = page.url?.toString() ?? currentPageUrl;
|
|
49
|
+
const composedHead = composeCoreSeoHead(head, metadata, {
|
|
50
|
+
canonicalUrl,
|
|
51
|
+
explicitHead: [head],
|
|
52
|
+
});
|
|
53
|
+
const dataValues = Array.isArray(structuredData) ? structuredData : [structuredData];
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
{composedHead.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)}
|
|
57
|
+
{dataValues.map((value) => (
|
|
58
|
+
<script type="application/ld+json" set:html={serializeJsonLd(value)} />
|
|
59
|
+
))}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hagicode/hagilight",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5-dev.43.1.d86178d",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared Astro components for HagiCode sites",
|
|
6
6
|
"repository": {
|
|
@@ -21,7 +21,14 @@
|
|
|
21
21
|
"./favicon": "./favicon.mjs",
|
|
22
22
|
"./site-links": "./site-links.ts",
|
|
23
23
|
"./GoogleAnalytics": "./GoogleAnalytics.astro",
|
|
24
|
-
"./Analytics51LA": "./Analytics51LA.astro"
|
|
24
|
+
"./Analytics51LA": "./Analytics51LA.astro",
|
|
25
|
+
"./SEOHead": "./SEOHead.astro",
|
|
26
|
+
"./seo-utils": "./seo-utils.mjs",
|
|
27
|
+
"./rss": "./rss-renderer.mjs",
|
|
28
|
+
"./seo-schema": {
|
|
29
|
+
"types": "./seo-schema.d.ts",
|
|
30
|
+
"default": "./seo-schema.mjs"
|
|
31
|
+
}
|
|
25
32
|
},
|
|
26
33
|
"files": [
|
|
27
34
|
"*.astro",
|
|
@@ -34,6 +41,9 @@
|
|
|
34
41
|
"peerDependencies": {
|
|
35
42
|
"astro": "^6.0.7 || ^7.3.5"
|
|
36
43
|
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@astrojs/rss": "^4.0.19"
|
|
46
|
+
},
|
|
37
47
|
"keywords": [
|
|
38
48
|
"astro-component",
|
|
39
49
|
"withastro",
|
package/rss-renderer.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import rss from '@astrojs/rss';
|
|
2
|
+
|
|
3
|
+
function resolveSite(value) {
|
|
4
|
+
try {
|
|
5
|
+
const site = new URL(value);
|
|
6
|
+
if (!['http:', 'https:'].includes(site.protocol) || site.username || site.password) {
|
|
7
|
+
throw new Error();
|
|
8
|
+
}
|
|
9
|
+
return site;
|
|
10
|
+
} catch {
|
|
11
|
+
throw new TypeError('Hagilight RSS requires an absolute Astro site URL; configure `site` in astro.config.mjs.');
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveLanguage(value) {
|
|
16
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
17
|
+
throw new TypeError('Hagilight RSS language must be a valid language tag.');
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
return Intl.getCanonicalLocales(value)[0];
|
|
21
|
+
} catch {
|
|
22
|
+
throw new TypeError(`Hagilight RSS language "${value}" must be a valid language tag.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveBaseUrl(site, baseUrl = '/') {
|
|
27
|
+
let base;
|
|
28
|
+
try {
|
|
29
|
+
base = new URL(baseUrl, site);
|
|
30
|
+
} catch {
|
|
31
|
+
throw new TypeError('Hagilight RSS base URL must be a valid path or absolute URL on the configured site.');
|
|
32
|
+
}
|
|
33
|
+
if (base.origin !== site.origin) {
|
|
34
|
+
throw new TypeError('Hagilight RSS base URL must use the configured Astro site origin.');
|
|
35
|
+
}
|
|
36
|
+
base.pathname = `${base.pathname.replace(/\/+$/u, '')}/`;
|
|
37
|
+
base.search = '';
|
|
38
|
+
base.hash = '';
|
|
39
|
+
return base;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveItemUrl(value, base) {
|
|
43
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
44
|
+
throw new TypeError('Hagilight RSS items require a non-empty link.');
|
|
45
|
+
}
|
|
46
|
+
let url;
|
|
47
|
+
try {
|
|
48
|
+
url = new URL(value);
|
|
49
|
+
} catch {
|
|
50
|
+
url = new URL(value.replace(/^\/+/u, ''), base);
|
|
51
|
+
}
|
|
52
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
53
|
+
throw new TypeError(`Hagilight RSS item link "${value}" must resolve to an absolute HTTP(S) URL.`);
|
|
54
|
+
}
|
|
55
|
+
return url.href;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolvePublicationDate(item, index) {
|
|
59
|
+
const value = item.pubDate ?? item.date;
|
|
60
|
+
if (value === undefined || value === null) return undefined;
|
|
61
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
62
|
+
if (Number.isNaN(date.getTime())) {
|
|
63
|
+
throw new TypeError(`Hagilight RSS item ${index + 1} has an invalid publication date.`);
|
|
64
|
+
}
|
|
65
|
+
return date;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function generateRssFeed({
|
|
69
|
+
site: siteInput,
|
|
70
|
+
baseUrl = '/',
|
|
71
|
+
language = 'en',
|
|
72
|
+
title,
|
|
73
|
+
description,
|
|
74
|
+
items,
|
|
75
|
+
} = {}) {
|
|
76
|
+
const site = resolveSite(siteInput);
|
|
77
|
+
const base = resolveBaseUrl(site, baseUrl);
|
|
78
|
+
const lang = resolveLanguage(language);
|
|
79
|
+
if (typeof title !== 'string' || !title.trim()) {
|
|
80
|
+
throw new TypeError('Hagilight RSS requires a non-empty feed title.');
|
|
81
|
+
}
|
|
82
|
+
if (typeof description !== 'string' || !description.trim()) {
|
|
83
|
+
throw new TypeError('Hagilight RSS requires a non-empty feed description.');
|
|
84
|
+
}
|
|
85
|
+
if (!Array.isArray(items)) {
|
|
86
|
+
throw new TypeError('Hagilight RSS items must be an array supplied by the consumer.');
|
|
87
|
+
}
|
|
88
|
+
const feedItems = items.map((item, index) => {
|
|
89
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
90
|
+
throw new TypeError(`Hagilight RSS item ${index + 1} must be an object.`);
|
|
91
|
+
}
|
|
92
|
+
if (typeof item.title !== 'string' || !item.title.trim()) {
|
|
93
|
+
throw new TypeError(`Hagilight RSS item ${index + 1} requires a non-empty title.`);
|
|
94
|
+
}
|
|
95
|
+
if (item.description !== undefined && typeof item.description !== 'string') {
|
|
96
|
+
throw new TypeError(`Hagilight RSS item ${index + 1} description must be a string.`);
|
|
97
|
+
}
|
|
98
|
+
const pubDate = resolvePublicationDate(item, index);
|
|
99
|
+
return {
|
|
100
|
+
title: item.title,
|
|
101
|
+
...(item.description === undefined ? {} : { description: item.description }),
|
|
102
|
+
link: resolveItemUrl(item.link, base),
|
|
103
|
+
...(pubDate === undefined ? {} : { pubDate }),
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return rss({
|
|
108
|
+
title,
|
|
109
|
+
description,
|
|
110
|
+
site: base,
|
|
111
|
+
customData: `<language>${lang}</language>`,
|
|
112
|
+
items: feedItems,
|
|
113
|
+
});
|
|
114
|
+
}
|
package/seo-schema.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ZodDate,
|
|
3
|
+
ZodObject,
|
|
4
|
+
ZodOptional,
|
|
5
|
+
ZodString,
|
|
6
|
+
} from "astro/zod";
|
|
7
|
+
|
|
8
|
+
export const seoSchema: ZodObject<{
|
|
9
|
+
seo: ZodOptional<
|
|
10
|
+
ZodObject<{
|
|
11
|
+
title: ZodOptional<ZodString>;
|
|
12
|
+
description: ZodOptional<ZodString>;
|
|
13
|
+
image: ZodOptional<ZodString>;
|
|
14
|
+
author: ZodOptional<ZodString>;
|
|
15
|
+
publishedDate: ZodOptional<ZodDate>;
|
|
16
|
+
}>
|
|
17
|
+
>;
|
|
18
|
+
}>;
|
|
19
|
+
|
|
20
|
+
export { seoSchema as seoFrontmatterSchema };
|
|
21
|
+
export { seoSchema as default };
|
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,264 @@
|
|
|
1
|
+
function normalizeBasePath(basePath = '/') {
|
|
2
|
+
const normalized = `/${String(basePath).replace(/^\/+|\/+$/gu, '')}`;
|
|
3
|
+
return normalized === '/' ? '' : normalized;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function resolveAbsoluteHttpUrl(value, label = 'URL') {
|
|
7
|
+
let parsed;
|
|
8
|
+
try {
|
|
9
|
+
parsed = new URL(value);
|
|
10
|
+
} catch {
|
|
11
|
+
throw new TypeError(`Hagilight SEO ${label} must be an absolute HTTP(S) URL.`);
|
|
12
|
+
}
|
|
13
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
|
14
|
+
throw new TypeError(`Hagilight SEO ${label} must be an absolute HTTP(S) URL.`);
|
|
15
|
+
}
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveCanonicalUrl(pageUrl, { site } = {}) {
|
|
20
|
+
const siteUrl = resolveAbsoluteHttpUrl(site, 'site');
|
|
21
|
+
const canonical = resolveAbsoluteHttpUrl(pageUrl, 'page URL');
|
|
22
|
+
if (canonical.origin !== siteUrl.origin) {
|
|
23
|
+
throw new TypeError('Hagilight SEO page URL must use the configured Astro site origin.');
|
|
24
|
+
}
|
|
25
|
+
canonical.search = '';
|
|
26
|
+
canonical.hash = '';
|
|
27
|
+
return canonical.href;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function extractSeoDescription(body) {
|
|
31
|
+
if (typeof body !== 'string' || !body.trim()) return undefined;
|
|
32
|
+
const text = body
|
|
33
|
+
.replace(/^```[\s\S]*?^```/gmu, ' ')
|
|
34
|
+
.replace(/^import\s.+;?\s*$/gmu, ' ')
|
|
35
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, '$1')
|
|
36
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
|
|
37
|
+
.replace(/<[^>]*>/gu, ' ')
|
|
38
|
+
.replace(/`([^`]+)`/gu, '$1')
|
|
39
|
+
.replace(/^\s{0,3}(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+\.\s+)/gmu, '')
|
|
40
|
+
.replace(/[*_~]/gu, '')
|
|
41
|
+
.replace(/\s+/gu, ' ')
|
|
42
|
+
.trim();
|
|
43
|
+
if (!text) return undefined;
|
|
44
|
+
|
|
45
|
+
const characters = Array.from(text);
|
|
46
|
+
if (characters.length <= 160) return text;
|
|
47
|
+
let end = 159;
|
|
48
|
+
const boundary = characters.slice(0, end).lastIndexOf(' ');
|
|
49
|
+
if (boundary > 120) end = boundary;
|
|
50
|
+
return `${characters.slice(0, end).join('').trimEnd()}…`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function metaKey(entry) {
|
|
54
|
+
if (entry?.tag !== 'meta') return undefined;
|
|
55
|
+
const attrs = entry.attrs ?? {};
|
|
56
|
+
if (typeof attrs.property === 'string') return `property:${attrs.property.toLowerCase()}`;
|
|
57
|
+
if (typeof attrs.name === 'string') return `name:${attrs.name.toLowerCase()}`;
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function explicitMetaKeys(heads) {
|
|
62
|
+
return new Set(heads.flatMap((head) => (Array.isArray(head) ? head : []))
|
|
63
|
+
.map(metaKey)
|
|
64
|
+
.filter(Boolean));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function explicitMetaValue(heads, key) {
|
|
68
|
+
return heads.flatMap((head) => (Array.isArray(head) ? head : []))
|
|
69
|
+
.find((entry) => metaKey(entry) === key)?.attrs?.content;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function existingMetaValue(head, key) {
|
|
73
|
+
return head.find((entry) => metaKey(entry) === key)?.attrs?.content;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveSeoMetadata({
|
|
77
|
+
entry,
|
|
78
|
+
head = [],
|
|
79
|
+
pageSeo = {},
|
|
80
|
+
siteSeo = {},
|
|
81
|
+
site,
|
|
82
|
+
basePath = '/',
|
|
83
|
+
explicitHead = [],
|
|
84
|
+
body,
|
|
85
|
+
} = {}) {
|
|
86
|
+
const data = entry?.data ?? {};
|
|
87
|
+
const title = pageSeo.title
|
|
88
|
+
?? siteSeo.title
|
|
89
|
+
?? data.title
|
|
90
|
+
?? existingMetaValue(head, 'property:og:title');
|
|
91
|
+
const description = pageSeo.description
|
|
92
|
+
?? data.description
|
|
93
|
+
?? explicitMetaValue(explicitHead, 'name:description')
|
|
94
|
+
?? explicitMetaValue(explicitHead, 'property:og:description')
|
|
95
|
+
?? extractSeoDescription(body)
|
|
96
|
+
?? existingMetaValue(head, 'name:description')
|
|
97
|
+
?? existingMetaValue(head, 'property:og:description')
|
|
98
|
+
?? siteSeo.description;
|
|
99
|
+
const image = pageSeo.image ?? siteSeo.image;
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
title,
|
|
103
|
+
description,
|
|
104
|
+
image: image === undefined ? undefined : resolveSeoImageUrl(image, { site, basePath }),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function isValidSeoImageReference(value) {
|
|
109
|
+
if (typeof value !== 'string' || value.trim() !== value || value.length === 0) return false;
|
|
110
|
+
if (value.startsWith('/')) return !value.startsWith('//');
|
|
111
|
+
try {
|
|
112
|
+
const url = new URL(value);
|
|
113
|
+
return (url.protocol === 'https:' || url.protocol === 'http:') && !url.username && !url.password;
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function resolveSeoImageUrl(value, { site, basePath = '/' } = {}) {
|
|
120
|
+
if (!isValidSeoImageReference(value)) {
|
|
121
|
+
throw new TypeError('Hagilight SEO image must be an absolute HTTP(S) URL or a site-root path.');
|
|
122
|
+
}
|
|
123
|
+
if (/^https?:\/\//iu.test(value)) return new URL(value).href;
|
|
124
|
+
const siteUrl = resolveAbsoluteHttpUrl(site, 'site');
|
|
125
|
+
const base = `${normalizeBasePath(basePath)}/`;
|
|
126
|
+
return new URL(`${base}${value.replace(/^\/+/u, '')}`, siteUrl).href;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function composeSeoHead(head, metadata, explicitHead = []) {
|
|
130
|
+
const explicit = explicitMetaKeys(explicitHead);
|
|
131
|
+
const values = [
|
|
132
|
+
['property:og:title', metadata.title],
|
|
133
|
+
['property:og:description', metadata.description],
|
|
134
|
+
['property:og:image', metadata.image],
|
|
135
|
+
['name:twitter:title', metadata.title],
|
|
136
|
+
['name:twitter:description', metadata.description],
|
|
137
|
+
['name:twitter:image', metadata.image],
|
|
138
|
+
];
|
|
139
|
+
const generatedKeys = new Set(values.map(([key]) => key));
|
|
140
|
+
const seen = new Set();
|
|
141
|
+
const result = head.filter((entry) => {
|
|
142
|
+
const key = metaKey(entry);
|
|
143
|
+
if (!key || !generatedKeys.has(key)) return true;
|
|
144
|
+
if (explicit.has(key)) {
|
|
145
|
+
if (seen.has(key)) return false;
|
|
146
|
+
seen.add(key);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
});
|
|
151
|
+
const present = new Set(result.map(metaKey).filter(Boolean));
|
|
152
|
+
|
|
153
|
+
for (const [key, content] of values) {
|
|
154
|
+
if (content === undefined || explicit.has(key) || present.has(key)) continue;
|
|
155
|
+
const separator = key.indexOf(':');
|
|
156
|
+
const attribute = key.slice(0, separator);
|
|
157
|
+
const value = key.slice(separator + 1);
|
|
158
|
+
result.push({ tag: 'meta', attrs: { [attribute]: value, content } });
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function composeCoreSeoHead(head, metadata, {
|
|
164
|
+
canonicalUrl,
|
|
165
|
+
explicitHead = [],
|
|
166
|
+
} = {}) {
|
|
167
|
+
const canonicalEntries = head.filter(({ tag, attrs }) => tag === 'link' && attrs?.rel === 'canonical');
|
|
168
|
+
const explicitCanonical = explicitHead.flatMap((entries) => Array.isArray(entries) ? entries : [])
|
|
169
|
+
.find(({ tag, attrs }) => tag === 'link' && attrs?.rel === 'canonical');
|
|
170
|
+
const canonicalSource = explicitCanonical?.attrs?.href
|
|
171
|
+
?? canonicalEntries[0]?.attrs?.href
|
|
172
|
+
?? canonicalUrl;
|
|
173
|
+
const canonical = resolveCanonicalUrl(canonicalSource, { site: metadata.site });
|
|
174
|
+
const withoutCanonical = head.filter(({ tag, attrs }) => !(tag === 'link' && attrs?.rel === 'canonical'));
|
|
175
|
+
const output = composeSeoHead(withoutCanonical, metadata, explicitHead);
|
|
176
|
+
const link = { tag: 'link', attrs: { rel: 'canonical', href: canonical } };
|
|
177
|
+
const insertionIndex = output.findIndex(({ tag }) => tag !== 'meta');
|
|
178
|
+
output.splice(insertionIndex < 0 ? output.length : insertionIndex, 0, link);
|
|
179
|
+
return output;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function dateValue(value, field) {
|
|
183
|
+
if (value === undefined || value === null) return undefined;
|
|
184
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
185
|
+
if (Number.isNaN(date.getTime())) {
|
|
186
|
+
throw new TypeError(`Hagilight SEO ${field} must be a valid date.`);
|
|
187
|
+
}
|
|
188
|
+
return date.toISOString();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function buildArticleStructuredData({
|
|
192
|
+
url,
|
|
193
|
+
title,
|
|
194
|
+
description,
|
|
195
|
+
author,
|
|
196
|
+
publishedDate,
|
|
197
|
+
modifiedDate,
|
|
198
|
+
} = {}) {
|
|
199
|
+
const canonicalUrl = resolveAbsoluteHttpUrl(url, 'article URL').href;
|
|
200
|
+
if (typeof title !== 'string' || !title.trim()) {
|
|
201
|
+
throw new TypeError('Hagilight SEO article title must be a non-empty string.');
|
|
202
|
+
}
|
|
203
|
+
const article = {
|
|
204
|
+
'@context': 'https://schema.org',
|
|
205
|
+
'@type': 'Article',
|
|
206
|
+
headline: title.trim(),
|
|
207
|
+
url: canonicalUrl,
|
|
208
|
+
mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
|
|
209
|
+
};
|
|
210
|
+
if (typeof description === 'string' && description.trim()) article.description = description.trim();
|
|
211
|
+
const published = dateValue(publishedDate, 'article publishedDate');
|
|
212
|
+
const modified = dateValue(modifiedDate, 'article modifiedDate');
|
|
213
|
+
if (published) article.datePublished = published;
|
|
214
|
+
if (modified) article.dateModified = modified;
|
|
215
|
+
if (typeof author === 'string' && author.trim()) {
|
|
216
|
+
article.author = { '@type': 'Person', name: author.trim() };
|
|
217
|
+
}
|
|
218
|
+
return article;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function buildBreadcrumbStructuredData(items = []) {
|
|
222
|
+
if (!Array.isArray(items)) throw new TypeError('Hagilight SEO breadcrumb items must be an array.');
|
|
223
|
+
return {
|
|
224
|
+
'@context': 'https://schema.org',
|
|
225
|
+
'@type': 'BreadcrumbList',
|
|
226
|
+
itemListElement: items.map(({ name, url }, index) => {
|
|
227
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
228
|
+
throw new TypeError('Hagilight SEO breadcrumb names must be non-empty strings.');
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
'@type': 'ListItem',
|
|
232
|
+
position: index + 1,
|
|
233
|
+
name: name.trim(),
|
|
234
|
+
item: resolveAbsoluteHttpUrl(url, 'breadcrumb URL').href,
|
|
235
|
+
};
|
|
236
|
+
}),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function buildOrganizationStructuredData({
|
|
241
|
+
name,
|
|
242
|
+
url,
|
|
243
|
+
logo,
|
|
244
|
+
alternateName,
|
|
245
|
+
} = {}) {
|
|
246
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
247
|
+
throw new TypeError('Hagilight SEO organization name must be a non-empty string.');
|
|
248
|
+
}
|
|
249
|
+
const organization = {
|
|
250
|
+
'@context': 'https://schema.org',
|
|
251
|
+
'@type': 'Organization',
|
|
252
|
+
name: name.trim(),
|
|
253
|
+
url: resolveAbsoluteHttpUrl(url, 'organization URL').href,
|
|
254
|
+
};
|
|
255
|
+
if (logo !== undefined) organization.logo = resolveAbsoluteHttpUrl(logo, 'organization logo').href;
|
|
256
|
+
if (typeof alternateName === 'string' && alternateName.trim()) {
|
|
257
|
+
organization.alternateName = alternateName.trim();
|
|
258
|
+
}
|
|
259
|
+
return organization;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function serializeJsonLd(value) {
|
|
263
|
+
return JSON.stringify(value).replace(/</gu, '\\u003c');
|
|
264
|
+
}
|