@hagicode/hagilight-starlight 0.2.3-dev.22.1.69e2b54 → 0.2.3-dev.23.1.f56e71c

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/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { fileURLToPath } from 'node:url';
3
+ import { resolveRssLocales, resolveRssOptions } from './rss-utils.mjs';
3
4
 
4
5
  const GOOGLE_ID_PATTERN = /^G-[A-Z0-9]+$/u;
5
6
  const FIFTY_ONE_LA_ID_PATTERN = /^[A-Za-z0-9_-]+$/u;
@@ -87,8 +88,16 @@ function getRssFeedUrl(config) {
87
88
  return rssLink?.attrs.href;
88
89
  }
89
90
 
90
- function createConfiguredIntegration(instanceId, serializedOptions, componentIds, getConfiguredRssFeed, generateRss) {
91
+ function createConfiguredIntegration(
92
+ instanceId,
93
+ serializedOptions,
94
+ rssConfig,
95
+ componentIds,
96
+ getConfiguredRssFeed,
97
+ generateRss,
98
+ ) {
91
99
  const optionsId = `virtual:hagilight-starlight/${instanceId}/options`;
100
+ const rssConfigId = 'virtual:hagilight-starlight/rss-config';
92
101
  const headerPath = componentIds.header
93
102
  ? fileURLToPath(new URL('./Header.astro', import.meta.url))
94
103
  : undefined;
@@ -167,7 +176,7 @@ const isNotFound = /(?:^|\\/)404(?:\\.html)?\\/?$/u.test(Astro.url.pathname);
167
176
  const vitePlugin = {
168
177
  name: `@hagicode/hagilight-starlight:${instanceId}`,
169
178
  resolveId(id) {
170
- return modules.has(id) || id === optionsId ? `\0${id}` : null;
179
+ return modules.has(id) || id === optionsId || id === rssConfigId ? `\0${id}` : null;
171
180
  },
172
181
  load(id) {
173
182
  if (!id.startsWith('\0')) return null;
@@ -178,6 +187,7 @@ const isNotFound = /(?:^|\\/)404(?:\\.html)?\\/?$/u.test(Astro.url.pathname);
178
187
  rssFeedUrl: getConfiguredRssFeed(),
179
188
  })};`;
180
189
  }
190
+ if (moduleId === rssConfigId) return `export default ${JSON.stringify(rssConfig)};`;
181
191
  return modules.get(moduleId) ?? null;
182
192
  },
183
193
  };
@@ -192,6 +202,11 @@ const isNotFound = /(?:^|\\/)404(?:\\.html)?\\/?$/u.test(Astro.url.pathname);
192
202
  entrypoint: fileURLToPath(new URL('./rss.xml.ts', import.meta.url)),
193
203
  prerender: true,
194
204
  });
205
+ injectRoute({
206
+ pattern: '/rss.[language].xml',
207
+ entrypoint: fileURLToPath(new URL('./rss.[language].xml.ts', import.meta.url)),
208
+ prerender: true,
209
+ });
195
210
  }
196
211
  updateConfig({ vite: { plugins: [vitePlugin] } });
197
212
  },
@@ -224,6 +239,7 @@ export default function hagilight(options = {}) {
224
239
  if (options.rss?.enabled !== undefined && typeof options.rss.enabled !== 'boolean') {
225
240
  throw new TypeError('Hagilight rss enabled option must be a boolean.');
226
241
  }
242
+ const rssOptions = resolveRssOptions(options.rss);
227
243
  if (options.analytics !== undefined
228
244
  && (!options.analytics || typeof options.analytics !== 'object' || Array.isArray(options.analytics))) {
229
245
  throw new TypeError('Hagilight analytics options must be an object.');
@@ -309,12 +325,14 @@ export default function hagilight(options = {}) {
309
325
  if (generateRss && !astroConfig.site) {
310
326
  throw new Error('Hagilight RSS requires the Astro site option. Set site or disable RSS with rss: { enabled: false }.');
311
327
  }
328
+ const rssLocales = generateRss ? resolveRssLocales(config.locales) : [];
312
329
  const rssFeedUrl = generateRss
313
330
  ? new URL('rss.xml', new URL((astroConfig.base ?? '/').replace(/\/?$/u, '/'), astroConfig.site)).toString()
314
331
  : undefined;
315
332
  addIntegration(createConfiguredIntegration(
316
333
  instanceId,
317
334
  serializedOptions,
335
+ { options: rssOptions, locales: rssLocales },
318
336
  componentIds,
319
337
  () => getRssFeedUrl(config) ?? rssFeedUrl,
320
338
  generateRss,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hagicode/hagilight-starlight",
3
- "version": "0.2.3-dev.22.1.69e2b54",
3
+ "version": "0.2.3-dev.23.1.f56e71c",
4
4
  "type": "module",
5
5
  "description": "Starlight customizations for HagiCode documentation",
6
6
  "repository": {
@@ -17,6 +17,7 @@
17
17
  "./locales": "./locales.mjs",
18
18
  "./article-promotion-schema": "./article-promotion-schema.mjs",
19
19
  "./ai-disclosure-schema": "./ai-disclosure-schema.mjs",
20
+ "./rss-schema": "./rss-schema.mjs",
20
21
  "./ContentLayoutToggle": "./ContentLayoutToggle.astro",
21
22
  "./Header": "./Header.astro",
22
23
  "./Footer": "./Footer.astro",
@@ -30,13 +31,13 @@
30
31
  "index.mjs",
31
32
  "*.css",
32
33
  "*.mjs",
33
- "rss.xml.ts",
34
+ "rss*.xml.ts",
34
35
  "*.astro",
35
36
  "assets/*.png"
36
37
  ],
37
38
  "dependencies": {
38
39
  "@astrojs/rss": "^4.0.19",
39
- "@hagicode/hagilight": "0.2.3-dev.22.1.69e2b54"
40
+ "@hagicode/hagilight": "0.2.3-dev.23.1.f56e71c"
40
41
  },
41
42
  "peerDependencies": {
42
43
  "@astrojs/starlight": "^0.42.4",
@@ -0,0 +1,31 @@
1
+ import rss from '@astrojs/rss';
2
+ import { selectRssEntries } from './rss-utils.mjs';
3
+
4
+ export function generateRssFeed({ site, baseUrl, filename, locales, options, entries }) {
5
+ if (!site) throw new Error('Hagilight RSS requires the Astro site option.');
6
+ const locale = locales.find((item) => item.filename === filename);
7
+ if (!locale && filename !== 'en') {
8
+ throw new Error(`Hagilight RSS has no configured locale for "${filename}".`);
9
+ }
10
+
11
+ const base = new URL(baseUrl, site);
12
+ const items = selectRssEntries(entries, { filename, locales, options }).map(({ id, data }) => {
13
+ const slug = id.replace(/(?:^|\/)index$/u, '');
14
+ return {
15
+ title: data.title,
16
+ description: data.description,
17
+ ...(data.lastUpdated instanceof Date && !Number.isNaN(data.lastUpdated.getTime())
18
+ ? { pubDate: data.lastUpdated }
19
+ : {}),
20
+ link: new URL(`${slug ? `${slug}/` : ''}`, base).toString(),
21
+ };
22
+ });
23
+
24
+ return rss({
25
+ title: 'Documentation',
26
+ description: 'Recently updated documentation pages',
27
+ site: base,
28
+ customData: `<language>${locale?.lang ?? 'en'}</language>`,
29
+ items,
30
+ });
31
+ }
package/rss-schema.mjs ADDED
@@ -0,0 +1,8 @@
1
+ import { z } from 'astro/zod';
2
+
3
+ const rssSchema = z.object({
4
+ rss: z.boolean().optional(),
5
+ });
6
+
7
+ export { rssSchema };
8
+ export default rssSchema;
package/rss-utils.mjs ADDED
@@ -0,0 +1,92 @@
1
+ const LANGUAGE_TAG_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u;
2
+
3
+ export function resolveRssOptions(options = {}) {
4
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
5
+ throw new TypeError('Hagilight rss options must be an object.');
6
+ }
7
+ for (const key of ['includeDocs', 'includeBlog']) {
8
+ if (options[key] !== undefined && typeof options[key] !== 'boolean') {
9
+ throw new TypeError(`Hagilight rss ${key} option must be a boolean.`);
10
+ }
11
+ }
12
+ return {
13
+ includeDocs: options.includeDocs ?? true,
14
+ includeBlog: options.includeBlog ?? true,
15
+ };
16
+ }
17
+
18
+ export function resolveRssLocales(locales) {
19
+ const configuredLocales = locales === undefined
20
+ ? [['root', { lang: 'en' }]]
21
+ : Object.entries(locales);
22
+ if (configuredLocales.length === 0) configuredLocales.push(['root', { lang: 'en' }]);
23
+
24
+ const usedFilenames = new Set();
25
+ const resolved = configuredLocales.map(([route, config]) => {
26
+ const lang = typeof config === 'string' ? config : config?.lang ?? (route === 'root' ? undefined : route);
27
+ if (typeof lang !== 'string' || !LANGUAGE_TAG_PATTERN.test(lang)) {
28
+ throw new TypeError(`Hagilight RSS locale "${route}" must have a valid language tag.`);
29
+ }
30
+ let normalizedLang;
31
+ try {
32
+ [normalizedLang] = Intl.getCanonicalLocales(lang);
33
+ } catch {
34
+ throw new TypeError(`Hagilight RSS locale "${route}" has an invalid language tag "${lang}".`);
35
+ }
36
+
37
+ const filename = /^en(?:-us)?$/iu.test(normalizedLang) ? 'en' : normalizedLang;
38
+ const collisionKey = filename.toLowerCase();
39
+ if (usedFilenames.has(collisionKey)) {
40
+ throw new Error(`Hagilight RSS locales collide on the "${filename}" feed filename.`);
41
+ }
42
+ usedFilenames.add(collisionKey);
43
+ return { route, lang: normalizedLang, filename };
44
+ });
45
+ return resolved;
46
+ }
47
+
48
+ function getLocaleForEntry(id, locales) {
49
+ const localized = locales
50
+ .filter(({ route }) => route !== 'root')
51
+ .sort((a, b) => b.route.length - a.route.length)
52
+ .find(({ route }) => id === route || id.startsWith(`${route}/`));
53
+ if (localized) return { locale: localized, relativeId: id.slice(localized.route.length).replace(/^\/+/, '') };
54
+
55
+ const root = locales.find(({ route }) => route === 'root');
56
+ if (root) return { locale: root, relativeId: id };
57
+ return undefined;
58
+ }
59
+
60
+ export function isBlogEntry(id) {
61
+ const segments = id.split('/').filter(Boolean);
62
+ if (segments.at(-1) === 'index') segments.pop();
63
+ return segments[0] === 'blog' && segments.length > 1;
64
+ }
65
+
66
+ export function selectRssEntries(entries, { filename, locales, options }) {
67
+ const locale = locales.find((item) => item.filename === filename);
68
+ if (!locale) {
69
+ if (filename === 'en') return [];
70
+ throw new Error(`Hagilight RSS has no configured locale for "${filename}".`);
71
+ }
72
+
73
+ return entries
74
+ .filter(({ id, data }) => {
75
+ if (data.rss !== undefined && typeof data.rss !== 'boolean') {
76
+ throw new TypeError(`Hagilight RSS frontmatter field "rss" for "${id}" must be a boolean.`);
77
+ }
78
+ if (data.draft || data.rss === false) return false;
79
+
80
+ const entryLocale = getLocaleForEntry(id, locales);
81
+ if (entryLocale?.locale.filename !== filename) return false;
82
+ const isBlog = isBlogEntry(entryLocale.relativeId);
83
+ return isBlog ? options.includeBlog : options.includeDocs;
84
+ })
85
+ .sort((a, b) => {
86
+ const aDate = a.data.lastUpdated instanceof Date ? a.data.lastUpdated.getTime() : undefined;
87
+ const bDate = b.data.lastUpdated instanceof Date ? b.data.lastUpdated.getTime() : undefined;
88
+ if (aDate === undefined || Number.isNaN(aDate)) return bDate === undefined || Number.isNaN(bDate) ? 0 : 1;
89
+ if (bDate === undefined || Number.isNaN(bDate)) return -1;
90
+ return bDate - aDate;
91
+ });
92
+ }
@@ -0,0 +1,20 @@
1
+ import { getCollection } from 'astro:content';
2
+ import type { APIRoute, GetStaticPaths } from 'astro';
3
+ import rssConfig from 'virtual:hagilight-starlight/rss-config';
4
+ import { generateRssFeed } from './rss-renderer.mjs';
5
+
6
+ export const getStaticPaths: GetStaticPaths = () => {
7
+ const filenames = new Set(['en', ...rssConfig.locales.map(({ filename }) => filename)]);
8
+ return [...filenames].map((language) => ({ params: { language } }));
9
+ };
10
+
11
+ export const GET: APIRoute = async ({ params, site }) => {
12
+ const entries = await getCollection('docs');
13
+ return generateRssFeed({
14
+ site,
15
+ baseUrl: import.meta.env.BASE_URL,
16
+ filename: params.language,
17
+ ...rssConfig,
18
+ entries,
19
+ });
20
+ };
package/rss.xml.ts CHANGED
@@ -1,25 +1,15 @@
1
- import rss from '@astrojs/rss';
2
1
  import { getCollection } from 'astro:content';
3
2
  import type { APIRoute } from 'astro';
3
+ import rssConfig from 'virtual:hagilight-starlight/rss-config';
4
+ import { generateRssFeed } from './rss-renderer.mjs';
4
5
 
5
6
  export const GET: APIRoute = async ({ site }) => {
6
- if (!site) throw new Error('Hagilight RSS requires the Astro site option.');
7
-
8
- const pages = await getCollection('docs', ({ data }) => !data.draft);
9
- const base = new URL(import.meta.env.BASE_URL, site);
10
- return rss({
11
- title: 'Documentation',
12
- description: 'Recently updated documentation pages',
13
- site: base,
14
- items: pages
15
- .sort((a, b) =>
16
- (b.data.lastUpdated instanceof Date ? b.data.lastUpdated.getTime() : 0)
17
- - (a.data.lastUpdated instanceof Date ? a.data.lastUpdated.getTime() : 0))
18
- .map(({ id, data }) => ({
19
- title: data.title,
20
- description: data.description,
21
- ...(data.lastUpdated instanceof Date ? { pubDate: data.lastUpdated } : {}),
22
- link: new URL(`${id.replace(/(?:^|\/)index$/u, '')}/`, base).toString(),
23
- })),
7
+ const entries = await getCollection('docs');
8
+ return generateRssFeed({
9
+ site,
10
+ baseUrl: import.meta.env.BASE_URL,
11
+ filename: 'en',
12
+ ...rssConfig,
13
+ entries,
24
14
  });
25
15
  };