@hagicode/hagilight 0.3.2-dev.55.1.1e29eba → 0.3.2-dev.57.1.ee1f17e

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/seo-utils.mjs DELETED
@@ -1,264 +0,0 @@
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
- }