@hagicode/hagilight 0.2.5 → 0.3.1

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/Footer.astro CHANGED
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Copyright from './Copyright.astro';
3
+ import { resolveRssFooterLinks } from './rss-footer-context.mjs';
3
4
  import { resolveSiteLinks, type SiteLinksOptions } from './site-links.ts';
4
5
 
5
6
  interface Props {
@@ -9,7 +10,8 @@ interface Props {
9
10
 
10
11
  const { locale: propLocale, links } = Astro.props;
11
12
  const locale = propLocale ?? Astro.currentLocale ?? 'en-US';
12
- const resolvedLinks = resolveSiteLinks(locale, links);
13
+ const footerLinks = resolveRssFooterLinks(Astro.locals, locale, links);
14
+ const resolvedLinks = resolveSiteLinks(locale, footerLinks);
13
15
  ---
14
16
 
15
17
  <footer class="hagilight-footer">
@@ -0,0 +1,191 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, extname, isAbsolute, resolve, win32 } from 'node:path';
4
+ import { resolveRssLocales } from './rss-locales.mjs';
5
+
6
+ export const HAGILIGHT_RSS_OWNER = Symbol.for('@hagicode/hagilight/rss-owner');
7
+ const VIRTUAL_RSS_CONFIG = 'virtual:hagilight/rss-config';
8
+ const VIRTUAL_RSS_CONFIG_RESOLVED = `\0${VIRTUAL_RSS_CONFIG}`;
9
+ const RSS_OWNER_REGISTRY = Symbol.for('@hagicode/hagilight/rss-owner-registry');
10
+ const PAGE_SOURCE_EXTENSIONS = new Set(['.astro', '.html', '.js', '.jsx', '.md', '.mdx', '.ts', '.tsx']);
11
+ const registeredRssOwners = globalThis[RSS_OWNER_REGISTRY] ?? new Map();
12
+ globalThis[RSS_OWNER_REGISTRY] = registeredRssOwners;
13
+
14
+ export function registerStarlightRssOwner(enabled) {
15
+ const token = Symbol('starlight-rss-owner');
16
+ registeredRssOwners.set(token, { package: 'starlight', enabled });
17
+ return () => registeredRssOwners.delete(token);
18
+ }
19
+
20
+ function resolveOptions(options) {
21
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
22
+ throw new TypeError('Hagilight RSS integration options must be an object.');
23
+ }
24
+ const locales = resolveRssLocales(options.locales, { requireNonEmpty: true });
25
+ if (typeof options.getFeed !== 'string' || !options.getFeed.trim()) {
26
+ throw new TypeError('Hagilight RSS getFeed must be a non-empty module path such as "./src/rss-feed.mjs".');
27
+ }
28
+ return { locales, getFeed: options.getFeed.trim() };
29
+ }
30
+
31
+ function resolveSite(site) {
32
+ let parsed;
33
+ try {
34
+ parsed = new URL(site);
35
+ } catch {
36
+ throw new Error('Hagilight RSS requires an absolute Astro site URL. Set `site` in astro.config.mjs.');
37
+ }
38
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
39
+ throw new Error('Hagilight RSS requires an absolute HTTP(S) Astro site URL.');
40
+ }
41
+ return parsed.href;
42
+ }
43
+
44
+ function resolveFeedModule(root, reference) {
45
+ if (reference.startsWith('file:') || isAbsolute(reference) || win32.isAbsolute(reference)) {
46
+ throw new TypeError('Hagilight RSS getFeed must be a path relative to the Astro project root.');
47
+ }
48
+ const rootPath = fileURLToPath(root);
49
+ const modulePath = resolve(rootPath, reference);
50
+ if (!existsSync(modulePath) || !statSync(modulePath).isFile()) {
51
+ throw new Error(`Hagilight RSS getFeed module "${reference}" was not found relative to the Astro project root.`);
52
+ }
53
+ return modulePath;
54
+ }
55
+
56
+ function routeCollisions(config, locales) {
57
+ const expectedFiles = new Set(['rss.xml', 'rss.en.xml', ...locales.map(({ filename }) => `rss.${filename}.xml`)]);
58
+ const pageRoot = resolve(fileURLToPath(config.srcDir), 'pages');
59
+ const publicRoot = fileURLToPath(config.publicDir);
60
+ const collisions = [];
61
+
62
+ function findPageCollisions(directory) {
63
+ if (!existsSync(directory)) return;
64
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
65
+ const entryPath = resolve(directory, entry.name);
66
+ if (entry.isDirectory()) {
67
+ if (entry.name === 'rss.xml' || /^rss\..+\.xml$/u.test(entry.name)) {
68
+ for (const child of readdirSync(entryPath, { withFileTypes: true })) {
69
+ if (child.isFile()
70
+ && child.name.startsWith('index.')
71
+ && PAGE_SOURCE_EXTENSIONS.has(extname(child.name))) {
72
+ collisions.push(resolve(entryPath, child.name));
73
+ }
74
+ }
75
+ }
76
+ findPageCollisions(entryPath);
77
+ } else if (entry.isFile()) {
78
+ const extension = extname(entry.name);
79
+ if (!PAGE_SOURCE_EXTENSIONS.has(extension)) continue;
80
+ const sourceName = entry.name.slice(0, -extension.length);
81
+ if (expectedFiles.has(sourceName) || /^rss\..+\.xml$/u.test(sourceName)) {
82
+ collisions.push(entryPath);
83
+ }
84
+ }
85
+ }
86
+ }
87
+ findPageCollisions(pageRoot);
88
+
89
+ for (const filename of expectedFiles) {
90
+ const publicFile = resolve(publicRoot, filename);
91
+ if (existsSync(publicFile) && statSync(publicFile).isFile()) collisions.push(publicFile);
92
+ }
93
+
94
+ if (collisions.length > 0) {
95
+ throw new Error(`Hagilight RSS route conflicts with consumer-owned file(s): ${collisions.join(', ')}. Remove the conflicting route or do not enable hagilightRss().`);
96
+ }
97
+ }
98
+
99
+ function findStarlightRss(config) {
100
+ const registeredOwners = [...registeredRssOwners.values()];
101
+ const owners = registeredOwners.length > 0
102
+ ? registeredOwners
103
+ : (config.integrations ?? [])
104
+ .map((integration) => integration?.[HAGILIGHT_RSS_OWNER])
105
+ .filter((owner) => owner?.package === 'starlight');
106
+ registeredRssOwners.clear();
107
+ return owners;
108
+ }
109
+
110
+ function createVirtualPlugin({ modulePath, site, baseUrl, locales, defaultFeedUrl, localeFeedUrls }) {
111
+ return {
112
+ name: '@hagicode/hagilight:rss',
113
+ resolveId(id) {
114
+ return id === VIRTUAL_RSS_CONFIG ? VIRTUAL_RSS_CONFIG_RESOLVED : null;
115
+ },
116
+ load(id) {
117
+ if (id !== VIRTUAL_RSS_CONFIG_RESOLVED) return null;
118
+ return [
119
+ `import * as siteFeedModule from ${JSON.stringify(modulePath)};`,
120
+ 'const config = {',
121
+ ` site: ${JSON.stringify(site)},`,
122
+ ` baseUrl: ${JSON.stringify(baseUrl)},`,
123
+ ` locales: ${JSON.stringify(locales)},`,
124
+ ' getFeed: siteFeedModule.default,',
125
+ ` footer: ${JSON.stringify({ defaultFeedUrl, localeFeedUrls, locales })},`,
126
+ '};',
127
+ 'export default config;',
128
+ ].join('\n');
129
+ },
130
+ };
131
+ }
132
+
133
+ export function hagilightRss(input) {
134
+ const options = resolveOptions(input);
135
+
136
+ return {
137
+ name: '@hagicode/hagilight:rss',
138
+ [HAGILIGHT_RSS_OWNER]: { package: 'core', enabled: true },
139
+ hooks: {
140
+ 'astro:config:setup'({ config, injectRoute, addMiddleware, updateConfig }) {
141
+ const starlightOwners = findStarlightRss(config);
142
+ const activeStarlightOwners = starlightOwners.filter((owner) => owner.enabled);
143
+ if (activeStarlightOwners.length > 1) {
144
+ throw new Error('Hagilight RSS cannot determine a sole route owner because multiple Starlight RSS integrations are enabled.');
145
+ }
146
+ if (activeStarlightOwners.length > 0) return;
147
+ if (starlightOwners.length > 0) {
148
+ throw new Error('Hagilight RSS cannot establish route ownership: Starlight RSS is explicitly disabled while hagilightRss() requests feed generation. Enable Starlight RSS or remove hagilightRss().');
149
+ }
150
+
151
+ const site = resolveSite(config.site);
152
+ const modulePath = resolveFeedModule(config.root, options.getFeed);
153
+ routeCollisions(config, options.locales);
154
+ const baseSegments = `${config.base || '/'}`
155
+ .split('/')
156
+ .filter(Boolean);
157
+ const baseUrl = baseSegments.length > 0 ? `/${baseSegments.join('/')}/` : '/';
158
+ const baseLocation = new URL(baseUrl, site);
159
+ const defaultFeedUrl = new URL('rss.xml', baseLocation).href;
160
+ const localeFeedUrls = Object.fromEntries(options.locales
161
+ .filter(({ filename }) => filename !== 'en')
162
+ .map(({ lang, filename }) => [lang, new URL(`rss.${filename}.xml`, baseLocation).href]));
163
+ const rssIntegrationPath = dirname(fileURLToPath(import.meta.url));
164
+ const plugin = createVirtualPlugin({
165
+ modulePath,
166
+ site,
167
+ baseUrl,
168
+ locales: options.locales,
169
+ defaultFeedUrl,
170
+ localeFeedUrls,
171
+ });
172
+
173
+ injectRoute({
174
+ pattern: '/rss.xml',
175
+ entrypoint: resolve(rssIntegrationPath, './rss.xml.ts'),
176
+ prerender: true,
177
+ });
178
+ injectRoute({
179
+ pattern: '/rss.[language].xml',
180
+ entrypoint: resolve(rssIntegrationPath, './rss.[language].xml.ts'),
181
+ prerender: true,
182
+ });
183
+ addMiddleware({
184
+ entrypoint: resolve(rssIntegrationPath, './rss-middleware.mjs'),
185
+ order: 'pre',
186
+ });
187
+ updateConfig({ vite: { plugins: [plugin] } });
188
+ },
189
+ },
190
+ };
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hagicode/hagilight",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Shared Astro components for HagiCode sites",
6
6
  "repository": {
@@ -23,7 +23,9 @@
23
23
  "./GoogleAnalytics": "./GoogleAnalytics.astro",
24
24
  "./Analytics51LA": "./Analytics51LA.astro",
25
25
  "./SEOHead": "./SEOHead.astro",
26
+ "./integration": "./integration.mjs",
26
27
  "./seo-utils": "./seo-utils.mjs",
28
+ "./rss-locales": "./rss-locales.mjs",
27
29
  "./rss": "./rss-renderer.mjs",
28
30
  "./seo-schema": {
29
31
  "types": "./seo-schema.d.ts",
@@ -0,0 +1,51 @@
1
+ function isRecord(value) {
2
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
3
+ }
4
+
5
+ function normalizeLanguageTag(value) {
6
+ if (typeof value !== 'string' || !value.trim()) return undefined;
7
+ const input = value.trim().replaceAll('_', '-');
8
+ const alias = input.toLowerCase();
9
+ if (['zh', 'zh-cn', 'zh-hans', 'root'].includes(alias)) return 'zh-CN';
10
+ if (['zh-hant', 'zh-tw', 'zh-hk'].includes(alias)) return 'zh-Hant';
11
+ try {
12
+ return Intl.getCanonicalLocales(input)[0];
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ }
17
+
18
+ export function resolveRssFooterLinks(locals, locale, links = {}) {
19
+ if (!isRecord(locals) || !Object.hasOwn(locals, 'hagilightRss')) return links;
20
+ const rss = locals.hagilightRss;
21
+ if (!isRecord(rss)
22
+ || typeof rss.defaultFeedUrl !== 'string'
23
+ || !rss.defaultFeedUrl
24
+ || !isRecord(rss.localeFeedUrls)
25
+ || !Array.isArray(rss.locales)
26
+ || rss.locales.some((entry) => !isRecord(entry)
27
+ || typeof entry.lang !== 'string'
28
+ || typeof entry.filename !== 'string')) {
29
+ throw new TypeError('Hagilight RSS Footer context is malformed.');
30
+ }
31
+
32
+ const normalizedLocale = normalizeLanguageTag(locale);
33
+ const configuredLocale = rss.locales.find((entry) =>
34
+ isRecord(entry)
35
+ && entry.filename !== 'en'
36
+ && normalizeLanguageTag(entry.lang) === normalizedLocale);
37
+ const localeFeedUrl = configuredLocale
38
+ ? rss.localeFeedUrls[configuredLocale.lang]
39
+ : undefined;
40
+ if (configuredLocale && (typeof localeFeedUrl !== 'string' || !localeFeedUrl)) {
41
+ throw new TypeError(`Hagilight RSS Footer context has no URL for "${configuredLocale.lang}".`);
42
+ }
43
+
44
+ return {
45
+ ...links,
46
+ rssFeedUrl: links.rssFeedUrl ?? rss.defaultFeedUrl,
47
+ ...(typeof localeFeedUrl === 'string' && localeFeedUrl
48
+ ? { rssLocaleFeedUrl: links.rssLocaleFeedUrl ?? localeFeedUrl }
49
+ : {}),
50
+ };
51
+ }
@@ -0,0 +1,40 @@
1
+ const LANGUAGE_TAG_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u;
2
+
3
+ export function resolveRssLocales(locales, { requireNonEmpty = false } = {}) {
4
+ if (locales !== undefined && (!locales || typeof locales !== 'object' || Array.isArray(locales))) {
5
+ throw new TypeError('Hagilight RSS locales must be an object.');
6
+ }
7
+ if (requireNonEmpty && (locales === undefined || Object.keys(locales).length === 0)) {
8
+ throw new TypeError('Hagilight RSS locales must contain at least one locale.');
9
+ }
10
+
11
+ const configuredLocales = locales === undefined
12
+ ? [['root', { lang: 'en' }]]
13
+ : Object.entries(locales);
14
+ if (configuredLocales.length === 0) {
15
+ if (requireNonEmpty) throw new TypeError('Hagilight RSS locales must contain at least one locale.');
16
+ configuredLocales.push(['root', { lang: 'en' }]);
17
+ }
18
+
19
+ const usedFilenames = new Set();
20
+ return configuredLocales.map(([route, config]) => {
21
+ const lang = typeof config === 'string' ? config : config?.lang ?? (route === 'root' ? undefined : route);
22
+ if (typeof lang !== 'string' || !LANGUAGE_TAG_PATTERN.test(lang)) {
23
+ throw new TypeError(`Hagilight RSS locale "${route}" must have a valid language tag.`);
24
+ }
25
+ let normalizedLang;
26
+ try {
27
+ [normalizedLang] = Intl.getCanonicalLocales(lang);
28
+ } catch {
29
+ throw new TypeError(`Hagilight RSS locale "${route}" has an invalid language tag "${lang}".`);
30
+ }
31
+
32
+ const filename = /^en(?:-us)?$/iu.test(normalizedLang) ? 'en' : normalizedLang;
33
+ const collisionKey = filename.toLowerCase();
34
+ if (usedFilenames.has(collisionKey)) {
35
+ throw new Error(`Hagilight RSS locales collide on the "${filename}" feed filename.`);
36
+ }
37
+ usedFilenames.add(collisionKey);
38
+ return { route, lang: normalizedLang, filename };
39
+ });
40
+ }
@@ -0,0 +1,6 @@
1
+ import rssConfig from 'virtual:hagilight/rss-config';
2
+
3
+ export const onRequest = (context, next) => {
4
+ context.locals.hagilightRss = rssConfig.footer;
5
+ return next();
6
+ };
@@ -0,0 +1,45 @@
1
+ import { generateRssFeed } from './rss-renderer.mjs';
2
+
3
+ const EMPTY_ENGLISH_FEED = {
4
+ title: 'Hagilight RSS Feed',
5
+ description: 'English-language feed for this site.',
6
+ items: [],
7
+ };
8
+
9
+ export async function renderRssFeed(config, filename) {
10
+ const locale = config.locales.find((entry) => entry.filename === filename);
11
+ if (!locale && filename !== 'en') {
12
+ throw new Error(`Hagilight RSS has no configured locale for "${filename}".`);
13
+ }
14
+
15
+ let feed;
16
+ if (!locale) {
17
+ feed = EMPTY_ENGLISH_FEED;
18
+ } else {
19
+ if (typeof config.getFeed !== 'function') {
20
+ throw new TypeError('Hagilight RSS getFeed module must default-export a callback function.');
21
+ }
22
+ feed = await config.getFeed({ route: locale.route, lang: locale.lang });
23
+ if (!feed || typeof feed !== 'object' || Array.isArray(feed)) {
24
+ throw new TypeError(`Hagilight RSS getFeed callback for "${locale.lang}" must return an object.`);
25
+ }
26
+ if (typeof feed.title !== 'string' || !feed.title.trim()) {
27
+ throw new TypeError(`Hagilight RSS getFeed callback for "${locale.lang}" must return a non-empty title.`);
28
+ }
29
+ if (typeof feed.description !== 'string' || !feed.description.trim()) {
30
+ throw new TypeError(`Hagilight RSS getFeed callback for "${locale.lang}" must return a non-empty description.`);
31
+ }
32
+ if (!Array.isArray(feed.items)) {
33
+ throw new TypeError(`Hagilight RSS getFeed callback for "${locale.lang}" must return an items array.`);
34
+ }
35
+ }
36
+
37
+ return generateRssFeed({
38
+ site: config.site,
39
+ baseUrl: config.baseUrl,
40
+ language: locale?.lang ?? 'en',
41
+ title: feed.title,
42
+ description: feed.description,
43
+ items: feed.items,
44
+ });
45
+ }
@@ -0,0 +1,10 @@
1
+ import type { APIRoute, GetStaticPaths } from 'astro';
2
+ import rssConfig from 'virtual:hagilight/rss-config';
3
+ import { renderRssFeed } from './rss-runtime.mjs';
4
+
5
+ export const getStaticPaths: GetStaticPaths = () => [...new Set([
6
+ 'en',
7
+ ...rssConfig.locales.map(({ filename }) => filename),
8
+ ])].map((language) => ({ params: { language } }));
9
+
10
+ export const GET: APIRoute = ({ params }) => renderRssFeed(rssConfig, params.language);
package/rss.xml.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { APIRoute } from 'astro';
2
+ import rssConfig from 'virtual:hagilight/rss-config';
3
+ import { renderRssFeed } from './rss-runtime.mjs';
4
+
5
+ export const GET: APIRoute = () => renderRssFeed(rssConfig, 'en');