@hagicode/hagilight-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/seo.js ADDED
@@ -0,0 +1,257 @@
1
+ function normalizeBasePath(basePath = '/') {
2
+ const normalized = `/${String(basePath).replace(/^\/+|\/+$/gu, '')}`;
3
+ return normalized === '/' ? '' : normalized;
4
+ }
5
+ export function resolveAbsoluteHttpUrl(value, label = 'URL') {
6
+ let parsed;
7
+ try {
8
+ parsed = new URL(value);
9
+ }
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
+ export function resolveCanonicalUrl(pageUrl, { site } = {}) {
19
+ const siteUrl = resolveAbsoluteHttpUrl(site, 'site');
20
+ const canonical = resolveAbsoluteHttpUrl(pageUrl, 'page URL');
21
+ if (canonical.origin !== siteUrl.origin) {
22
+ throw new TypeError('Hagilight SEO page URL must use the configured Astro site origin.');
23
+ }
24
+ canonical.search = '';
25
+ canonical.hash = '';
26
+ return canonical.href;
27
+ }
28
+ export function extractSeoDescription(body) {
29
+ if (typeof body !== 'string' || !body.trim())
30
+ return undefined;
31
+ const text = body
32
+ .replace(/^```[\s\S]*?^```/gmu, ' ')
33
+ .replace(/^import\s.+;?\s*$/gmu, ' ')
34
+ .replace(/!\[([^\]]*)\]\([^)]*\)/gu, '$1')
35
+ .replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
36
+ .replace(/<[^>]*>/gu, ' ')
37
+ .replace(/`([^`]+)`/gu, '$1')
38
+ .replace(/^\s{0,3}(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+\.\s+)/gmu, '')
39
+ .replace(/[*_~]/gu, '')
40
+ .replace(/\s+/gu, ' ')
41
+ .trim();
42
+ if (!text)
43
+ return undefined;
44
+ const characters = Array.from(text);
45
+ if (characters.length <= 160)
46
+ return text;
47
+ let end = 159;
48
+ const boundary = characters.slice(0, end).lastIndexOf(' ');
49
+ if (boundary > 120)
50
+ end = boundary;
51
+ return `${characters.slice(0, end).join('').trimEnd()}…`;
52
+ }
53
+ function metaKey(entry) {
54
+ if (entry?.tag !== 'meta')
55
+ return undefined;
56
+ const attrs = entry.attrs ?? {};
57
+ if (typeof attrs.property === 'string')
58
+ return `property:${attrs.property.toLowerCase()}`;
59
+ if (typeof attrs.name === 'string')
60
+ return `name:${attrs.name.toLowerCase()}`;
61
+ return undefined;
62
+ }
63
+ function flattenHeads(heads) {
64
+ return heads.flatMap((head) => (Array.isArray(head) ? head : []));
65
+ }
66
+ function explicitMetaKeys(heads) {
67
+ return new Set(flattenHeads(heads).map(metaKey).filter((key) => Boolean(key)));
68
+ }
69
+ function metaContent(entry) {
70
+ const content = entry?.attrs?.content;
71
+ return typeof content === 'string' ? content : undefined;
72
+ }
73
+ function explicitMetaValue(heads, key) {
74
+ return metaContent(flattenHeads(heads).find((entry) => metaKey(entry) === key));
75
+ }
76
+ function existingMetaValue(head, key) {
77
+ return metaContent(head.find((entry) => metaKey(entry) === key));
78
+ }
79
+ export function resolveSeoMetadata({ entry, head = [], pageSeo = {}, siteSeo = {}, site, basePath = '/', explicitHead = [], body, } = {}) {
80
+ const data = entry?.data ?? {};
81
+ const title = pageSeo.title
82
+ ?? siteSeo.title
83
+ ?? data.title
84
+ ?? existingMetaValue(head, 'property:og:title');
85
+ const description = pageSeo.description
86
+ ?? data.description
87
+ ?? explicitMetaValue(explicitHead, 'name:description')
88
+ ?? explicitMetaValue(explicitHead, 'property:og:description')
89
+ ?? extractSeoDescription(body)
90
+ ?? existingMetaValue(head, 'name:description')
91
+ ?? existingMetaValue(head, 'property:og:description')
92
+ ?? siteSeo.description;
93
+ const image = pageSeo.image ?? siteSeo.image;
94
+ return {
95
+ title,
96
+ description,
97
+ image: image === undefined ? undefined : resolveSeoImageUrl(image, { site, basePath }),
98
+ };
99
+ }
100
+ export function isValidSeoImageReference(value) {
101
+ if (typeof value !== 'string' || value.trim() !== value || value.length === 0)
102
+ return false;
103
+ if (value.startsWith('/'))
104
+ return !value.startsWith('//');
105
+ try {
106
+ const url = new URL(value);
107
+ return (url.protocol === 'https:' || url.protocol === 'http:') && !url.username && !url.password;
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ export function resolveSeoImageUrl(value, { site, basePath = '/' } = {}) {
114
+ if (!isValidSeoImageReference(value)) {
115
+ throw new TypeError('Hagilight SEO image must be an absolute HTTP(S) URL or a site-root path.');
116
+ }
117
+ if (/^https?:\/\//iu.test(value))
118
+ return new URL(value).href;
119
+ const siteUrl = resolveAbsoluteHttpUrl(site, 'site');
120
+ const base = `${normalizeBasePath(basePath)}/`;
121
+ return new URL(`${base}${value.replace(/^\/+/u, '')}`, siteUrl).href;
122
+ }
123
+ export function composeSeoHead(head, metadata, explicitHead = []) {
124
+ const explicit = explicitMetaKeys(explicitHead);
125
+ const values = [
126
+ ['property:og:title', metadata.title],
127
+ ['property:og:description', metadata.description],
128
+ ['property:og:image', metadata.image],
129
+ ['name:twitter:title', metadata.title],
130
+ ['name:twitter:description', metadata.description],
131
+ ['name:twitter:image', metadata.image],
132
+ ];
133
+ const generatedKeys = new Set(values.map(([key]) => key));
134
+ const seen = new Set();
135
+ const result = head.filter((entry) => {
136
+ const key = metaKey(entry);
137
+ if (!key || !generatedKeys.has(key))
138
+ return true;
139
+ if (explicit.has(key)) {
140
+ if (seen.has(key))
141
+ return false;
142
+ seen.add(key);
143
+ return true;
144
+ }
145
+ return false;
146
+ });
147
+ const present = new Set(result.map(metaKey).filter(Boolean));
148
+ for (const [key, content] of values) {
149
+ if (content === undefined || explicit.has(key) || present.has(key))
150
+ continue;
151
+ const separator = key.indexOf(':');
152
+ const attribute = key.slice(0, separator);
153
+ const value = key.slice(separator + 1);
154
+ result.push({ tag: 'meta', attrs: { [attribute]: value, content } });
155
+ }
156
+ return result;
157
+ }
158
+ function isCanonicalLink(entry) {
159
+ return entry.tag === 'link' && entry.attrs?.rel === 'canonical';
160
+ }
161
+ function canonicalHref(entry) {
162
+ const href = entry?.attrs?.href;
163
+ return typeof href === 'string' ? href : undefined;
164
+ }
165
+ /**
166
+ * Compose Open Graph/Twitter metadata plus exactly one canonical link for a
167
+ * plain Astro page. An explicit canonical link wins over existing ones, which
168
+ * win over `canonicalUrl`.
169
+ */
170
+ export function composeCanonicalSeoHead(head, metadata, { canonicalUrl, explicitHead = [] } = {}) {
171
+ const canonicalSource = canonicalHref(flattenHeads(explicitHead).find(isCanonicalLink))
172
+ ?? canonicalHref(head.find(isCanonicalLink))
173
+ ?? canonicalUrl;
174
+ if (canonicalSource === undefined) {
175
+ throw new TypeError('Hagilight SEO page URL must be an absolute HTTP(S) URL.');
176
+ }
177
+ const canonical = resolveCanonicalUrl(canonicalSource, { site: metadata.site });
178
+ const output = composeSeoHead(head.filter((entry) => !isCanonicalLink(entry)), metadata, explicitHead);
179
+ const link = { tag: 'link', attrs: { rel: 'canonical', href: canonical } };
180
+ const insertionIndex = output.findIndex(({ tag }) => tag !== 'meta');
181
+ output.splice(insertionIndex < 0 ? output.length : insertionIndex, 0, link);
182
+ return output;
183
+ }
184
+ function dateValue(value, field) {
185
+ if (value === undefined || value === null)
186
+ return undefined;
187
+ const date = value instanceof Date ? value : new Date(value);
188
+ if (Number.isNaN(date.getTime())) {
189
+ throw new TypeError(`Hagilight SEO ${field} must be a valid date.`);
190
+ }
191
+ return date.toISOString();
192
+ }
193
+ export function buildArticleStructuredData({ url, title, description, author, publishedDate, modifiedDate, }) {
194
+ const canonicalUrl = resolveAbsoluteHttpUrl(url, 'article URL').href;
195
+ if (typeof title !== 'string' || !title.trim()) {
196
+ throw new TypeError('Hagilight SEO article title must be a non-empty string.');
197
+ }
198
+ const article = {
199
+ '@context': 'https://schema.org',
200
+ '@type': 'Article',
201
+ headline: title.trim(),
202
+ url: canonicalUrl,
203
+ mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
204
+ };
205
+ if (typeof description === 'string' && description.trim())
206
+ article.description = description.trim();
207
+ const published = dateValue(publishedDate, 'article publishedDate');
208
+ const modified = dateValue(modifiedDate, 'article modifiedDate');
209
+ if (published)
210
+ article.datePublished = published;
211
+ if (modified)
212
+ article.dateModified = modified;
213
+ if (typeof author === 'string' && author.trim()) {
214
+ article.author = { '@type': 'Person', name: author.trim() };
215
+ }
216
+ return article;
217
+ }
218
+ export function buildBreadcrumbStructuredData(items = []) {
219
+ if (!Array.isArray(items))
220
+ throw new TypeError('Hagilight SEO breadcrumb items must be an array.');
221
+ return {
222
+ '@context': 'https://schema.org',
223
+ '@type': 'BreadcrumbList',
224
+ itemListElement: items.map(({ name, url }, index) => {
225
+ if (typeof name !== 'string' || !name.trim()) {
226
+ throw new TypeError('Hagilight SEO breadcrumb names must be non-empty strings.');
227
+ }
228
+ return {
229
+ '@type': 'ListItem',
230
+ position: index + 1,
231
+ name: name.trim(),
232
+ item: resolveAbsoluteHttpUrl(url, 'breadcrumb URL').href,
233
+ };
234
+ }),
235
+ };
236
+ }
237
+ export function buildOrganizationStructuredData({ name, url, logo, alternateName, }) {
238
+ if (typeof name !== 'string' || !name.trim()) {
239
+ throw new TypeError('Hagilight SEO organization name must be a non-empty string.');
240
+ }
241
+ const organization = {
242
+ '@context': 'https://schema.org',
243
+ '@type': 'Organization',
244
+ name: name.trim(),
245
+ url: resolveAbsoluteHttpUrl(url, 'organization URL').href,
246
+ };
247
+ if (logo !== undefined)
248
+ organization.logo = resolveAbsoluteHttpUrl(logo, 'organization logo').href;
249
+ if (typeof alternateName === 'string' && alternateName.trim()) {
250
+ organization.alternateName = alternateName.trim();
251
+ }
252
+ return organization;
253
+ }
254
+ /** Serialize JSON-LD safely for inline `<script type="application/ld+json">`. */
255
+ export function serializeJsonLd(value) {
256
+ return JSON.stringify(value).replace(/</gu, '\\u003c');
257
+ }
package/favicon.ico ADDED
Binary file
package/logo.png ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@hagicode/hagilight-core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Shared TypeScript utilities, components, and assets for HagiCode Astro and Starlight sites",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/HagiCode-org/hagilight.git",
9
+ "directory": "packages/core"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "provenance": true
14
+ },
15
+ "scripts": {
16
+ "build": "tsc -b",
17
+ "prepack": "tsc -b"
18
+ },
19
+ "exports": {
20
+ "./Footer": "./Footer.astro",
21
+ "./links": {
22
+ "types": "./dist/links.d.ts",
23
+ "default": "./dist/links.js"
24
+ },
25
+ "./favicon": {
26
+ "types": "./dist/favicon.d.ts",
27
+ "default": "./dist/favicon.js"
28
+ },
29
+ "./seo": {
30
+ "types": "./dist/seo.d.ts",
31
+ "default": "./dist/seo.js"
32
+ },
33
+ "./seo-schema": {
34
+ "types": "./dist/seo-schema.d.ts",
35
+ "default": "./dist/seo-schema.js"
36
+ },
37
+ "./rss": {
38
+ "types": "./dist/rss.d.ts",
39
+ "default": "./dist/rss.js"
40
+ },
41
+ "./rss-ownership": {
42
+ "types": "./dist/rss-ownership.d.ts",
43
+ "default": "./dist/rss-ownership.js"
44
+ },
45
+ "./promotions": {
46
+ "types": "./dist/promotions.d.ts",
47
+ "default": "./dist/promotions.js"
48
+ },
49
+ "./Copyright": "./Copyright.astro",
50
+ "./PromotoBanner": "./PromotoBanner.astro",
51
+ "./GoogleAnalytics": "./GoogleAnalytics.astro",
52
+ "./Analytics51LA": "./Analytics51LA.astro",
53
+ "./logo.png": "./logo.png",
54
+ "./favicon.ico": "./favicon.ico"
55
+ },
56
+ "files": [
57
+ "dist/**/*.js",
58
+ "dist/**/*.d.ts",
59
+ "dist/**/*.json",
60
+ "*.astro",
61
+ "logo.png",
62
+ "favicon.ico"
63
+ ],
64
+ "peerDependencies": {
65
+ "astro": "^6.0.7 || ^7.3.5"
66
+ },
67
+ "devDependencies": {
68
+ "astro": "^7.3.5"
69
+ },
70
+ "dependencies": {
71
+ "@astrojs/rss": "^4.0.19"
72
+ },
73
+ "keywords": [
74
+ "astro-component",
75
+ "withastro",
76
+ "astro-components",
77
+ "typescript"
78
+ ]
79
+ }