@hagicode/hagilight 0.3.1 → 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/integration.mjs DELETED
@@ -1,191 +0,0 @@
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/logo.png DELETED
Binary file
package/promotions.ts DELETED
@@ -1,263 +0,0 @@
1
- const INDEX_ORIGIN = 'https://index.hagicode.com';
2
- const CATALOG_URL = `${INDEX_ORIGIN}/index-catalog.json`;
3
- const FLAGS_URL = `${INDEX_ORIGIN}/promote.json`;
4
- const CONTENT_URL = `${INDEX_ORIGIN}/promote_content.json`;
5
-
6
- type JsonRecord = Record<string, unknown>;
7
- type FetchLike = typeof fetch;
8
-
9
- export interface PromotionImage {
10
- src: string;
11
- alt: string;
12
- variant?: string;
13
- width?: number;
14
- height?: number;
15
- }
16
-
17
- export interface PromotionCard {
18
- id: string;
19
- title: string;
20
- description: string;
21
- ctaLabel: string;
22
- link: string;
23
- image?: PromotionImage;
24
- }
25
-
26
- interface PromotionFlag {
27
- id: string;
28
- on: boolean;
29
- startTime?: string;
30
- endTime?: string;
31
- }
32
-
33
- interface PromotionContent {
34
- id: string;
35
- title: Record<string, string>;
36
- description: Record<string, string>;
37
- cta?: Record<string, string>;
38
- link: string;
39
- image?: PromotionImage;
40
- }
41
-
42
- function isRecord(value: unknown): value is JsonRecord {
43
- return typeof value === 'object' && value !== null && !Array.isArray(value);
44
- }
45
-
46
- function nonEmptyString(value: unknown): value is string {
47
- return typeof value === 'string' && value.trim().length > 0;
48
- }
49
-
50
- function parseLocalizedMap(value: unknown): Record<string, string> | null {
51
- if (!isRecord(value)) return null;
52
- const entries = Object.entries(value).filter(
53
- (entry): entry is [string, string] => nonEmptyString(entry[0]) && nonEmptyString(entry[1]),
54
- );
55
- return entries.length ? Object.fromEntries(entries.map(([key, text]) => [key.trim(), text.trim()])) : null;
56
- }
57
-
58
- function parseImage(value: unknown, record: JsonRecord): PromotionImage | undefined {
59
- const image = isRecord(value) ? value : {};
60
- const src = nonEmptyString(value)
61
- ? value.trim()
62
- : nonEmptyString(image.src)
63
- ? image.src.trim()
64
- : nonEmptyString(image.url)
65
- ? image.url.trim()
66
- : nonEmptyString(record.imageUrl)
67
- ? record.imageUrl.trim()
68
- : nonEmptyString(record.imageURL)
69
- ? record.imageURL.trim()
70
- : '';
71
- if (!src) return undefined;
72
-
73
- const width = typeof image.width === 'number' && Number.isFinite(image.width) && image.width > 0
74
- ? Math.round(image.width)
75
- : undefined;
76
- const height = typeof image.height === 'number' && Number.isFinite(image.height) && image.height > 0
77
- ? Math.round(image.height)
78
- : undefined;
79
-
80
- let normalizedSrc: string;
81
- try {
82
- const url = new URL(src, INDEX_ORIGIN);
83
- if (!['http:', 'https:'].includes(url.protocol)) return undefined;
84
- normalizedSrc = url.toString();
85
- } catch {
86
- return undefined;
87
- }
88
-
89
- return {
90
- src: normalizedSrc,
91
- alt: nonEmptyString(image.alt)
92
- ? image.alt.trim()
93
- : nonEmptyString(record.imageAlt)
94
- ? record.imageAlt.trim()
95
- : '',
96
- variant: nonEmptyString(image.variant) ? image.variant.trim() : undefined,
97
- width,
98
- height,
99
- };
100
- }
101
-
102
- function isSafeLink(value: string): boolean {
103
- try {
104
- return ['http:', 'https:'].includes(new URL(value, INDEX_ORIGIN).protocol);
105
- } catch {
106
- return false;
107
- }
108
- }
109
-
110
- export function parsePromotionFlags(payload: unknown): PromotionFlag[] {
111
- if (!isRecord(payload) || !Array.isArray(payload.promotes)) return [];
112
- return payload.promotes.flatMap((item): PromotionFlag[] => {
113
- if (!isRecord(item) || !nonEmptyString(item.id) || typeof item.on !== 'boolean') return [];
114
- return [{
115
- id: item.id.trim(),
116
- on: item.on,
117
- startTime: nonEmptyString(item.startTime) ? item.startTime.trim() : undefined,
118
- endTime: nonEmptyString(item.endTime) ? item.endTime.trim() : undefined,
119
- }];
120
- });
121
- }
122
-
123
- export function parsePromotionContent(payload: unknown): PromotionContent[] {
124
- if (!isRecord(payload) || !Array.isArray(payload.contents)) return [];
125
- return payload.contents.flatMap((item): PromotionContent[] => {
126
- if (!isRecord(item) || !nonEmptyString(item.id) || !nonEmptyString(item.link) || !isSafeLink(item.link)) {
127
- return [];
128
- }
129
- const title = parseLocalizedMap(item.title);
130
- const description = parseLocalizedMap(item.description);
131
- if (!title || !description) return [];
132
- const cta = parseLocalizedMap(item.cta);
133
- const image = parseImage(item.image, item);
134
- return [{
135
- id: item.id.trim(),
136
- title,
137
- description,
138
- cta: cta ?? undefined,
139
- link: item.link.trim(),
140
- image,
141
- }];
142
- });
143
- }
144
-
145
- function localeCandidates(locale: string | null | undefined): string[] {
146
- let canonical = 'en-US';
147
- if (locale?.trim()) {
148
- try {
149
- canonical = Intl.getCanonicalLocales(locale.trim().replace(/_/g, '-'))[0] ?? 'en-US';
150
- } catch {
151
- canonical = 'en-US';
152
- }
153
- }
154
- const normalized = canonical.toLowerCase();
155
- const candidates = [normalized, normalized.split('-')[0] ?? normalized];
156
- if (normalized.startsWith('zh-hant') || ['zh-tw', 'zh-hk', 'zh-mo'].includes(normalized)) {
157
- candidates.push('zh-hant', 'zh-cn', 'zh');
158
- } else if (normalized.startsWith('zh')) {
159
- candidates.push('zh-cn', 'zh-hant', 'zh');
160
- }
161
- candidates.push('en-us', 'en');
162
- return [...new Set(candidates)];
163
- }
164
-
165
- function localized(value: Record<string, string>, locale: string | null | undefined): string | null {
166
- const values = new Map(Object.entries(value).map(([key, text]) => [key.toLowerCase(), text]));
167
- for (const candidate of localeCandidates(locale)) {
168
- const result = values.get(candidate);
169
- if (result) return result;
170
- }
171
- return Object.values(value)[0] ?? null;
172
- }
173
-
174
- function isActive(flag: PromotionFlag, now: number): boolean {
175
- if (!flag.on) return false;
176
- const start = flag.startTime ? Date.parse(flag.startTime) : null;
177
- const end = flag.endTime ? Date.parse(flag.endTime) : null;
178
- if ((start !== null && !Number.isFinite(start)) || (end !== null && !Number.isFinite(end))) return false;
179
- if (start !== null && end !== null && start >= end) return false;
180
- return (start === null || now >= start) && (end === null || now < end);
181
- }
182
-
183
- export function normalizeActivePromotions(
184
- flags: PromotionFlag[],
185
- content: PromotionContent[],
186
- locale?: string | null,
187
- now = Date.now(),
188
- ): PromotionCard[] {
189
- const contentById = new Map(content.map((item) => [item.id, item]));
190
- return flags.flatMap((flag): PromotionCard[] => {
191
- const item = contentById.get(flag.id);
192
- if (!isActive(flag, now) || !item) return [];
193
- const title = localized(item.title, locale);
194
- const description = localized(item.description, locale);
195
- if (!title || !description) return [];
196
- return [{
197
- id: item.id,
198
- title,
199
- description,
200
- ctaLabel: (item.cta && localized(item.cta, locale)) || 'Learn more',
201
- link: item.link,
202
- image: item.image ? { ...item.image, alt: item.image.alt || title } : undefined,
203
- }];
204
- });
205
- }
206
-
207
- async function readJson(fetchImpl: FetchLike, url: string): Promise<unknown> {
208
- const response = await fetchImpl(url, { headers: { accept: 'application/json' }, cache: 'no-store' });
209
- if (!response.ok) throw new Error(`Failed to load promotion data (${response.status})`);
210
- const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
211
- if (!contentType.includes('json')) throw new Error(`Expected JSON promotion data, received ${contentType || 'no content type'}`);
212
- return response.json();
213
- }
214
-
215
- export async function resolvePromotionDocumentUrls(fetchImpl: FetchLike = fetch): Promise<{
216
- flagsUrl: string;
217
- contentUrl: string;
218
- source: 'catalog' | 'fallback';
219
- }> {
220
- try {
221
- const catalog = await readJson(fetchImpl, CATALOG_URL);
222
- if (isRecord(catalog) && Array.isArray(catalog.entries)) {
223
- const entries = catalog.entries.filter(isRecord);
224
- const flags = entries.find((entry) => entry.id === 'promotion-flags' && nonEmptyString(entry.path));
225
- const content = entries.find((entry) => entry.id === 'promotion-content' && nonEmptyString(entry.path));
226
- const flagsPath = flags?.path;
227
- const contentPath = content?.path;
228
- if (nonEmptyString(flagsPath) && nonEmptyString(contentPath)) {
229
- const flagsUrl = new URL(flagsPath, INDEX_ORIGIN);
230
- const contentUrl = new URL(contentPath, INDEX_ORIGIN);
231
- if (flagsUrl.origin !== INDEX_ORIGIN || contentUrl.origin !== INDEX_ORIGIN) {
232
- return { flagsUrl: FLAGS_URL, contentUrl: CONTENT_URL, source: 'fallback' };
233
- }
234
- return {
235
- flagsUrl: flagsUrl.toString(),
236
- contentUrl: contentUrl.toString(),
237
- source: 'catalog',
238
- };
239
- }
240
- }
241
- } catch {
242
- // The stable canonical endpoints remain available when catalog discovery fails.
243
- }
244
- return { flagsUrl: FLAGS_URL, contentUrl: CONTENT_URL, source: 'fallback' };
245
- }
246
-
247
- export async function loadActivePromotions(options: {
248
- locale?: string | null;
249
- fetchImpl?: FetchLike;
250
- now?: number;
251
- } = {}): Promise<PromotionCard[]> {
252
- const { locale, fetchImpl = fetch, now = Date.now() } = options;
253
- try {
254
- const urls = await resolvePromotionDocumentUrls(fetchImpl);
255
- const [flags, content] = await Promise.all([
256
- readJson(fetchImpl, urls.flagsUrl),
257
- readJson(fetchImpl, urls.contentUrl),
258
- ]);
259
- return normalizeActivePromotions(parsePromotionFlags(flags), parsePromotionContent(content), locale, now);
260
- } catch {
261
- return [];
262
- }
263
- }