@hagicode/hagilight-starlight 0.2.2 → 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) {
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
  };
@@ -185,7 +195,19 @@ const isNotFound = /(?:^|\\/)404(?:\\.html)?\\/?$/u.test(Astro.url.pathname);
185
195
  return {
186
196
  name: `@hagicode/hagilight-starlight:${instanceId}`,
187
197
  hooks: {
188
- 'astro:config:setup'({ updateConfig }) {
198
+ 'astro:config:setup'({ injectRoute, updateConfig }) {
199
+ if (generateRss) {
200
+ injectRoute({
201
+ pattern: '/rss.xml',
202
+ entrypoint: fileURLToPath(new URL('./rss.xml.ts', import.meta.url)),
203
+ prerender: true,
204
+ });
205
+ injectRoute({
206
+ pattern: '/rss.[language].xml',
207
+ entrypoint: fileURLToPath(new URL('./rss.[language].xml.ts', import.meta.url)),
208
+ prerender: true,
209
+ });
210
+ }
189
211
  updateConfig({ vite: { plugins: [vitePlugin] } });
190
212
  },
191
213
  },
@@ -210,6 +232,14 @@ export default function hagilight(options = {}) {
210
232
  if (options.notFoundPage?.enabled !== undefined && typeof options.notFoundPage.enabled !== 'boolean') {
211
233
  throw new TypeError('Hagilight notFoundPage enabled option must be a boolean.');
212
234
  }
235
+ if (options.rss !== undefined
236
+ && (!options.rss || typeof options.rss !== 'object' || Array.isArray(options.rss))) {
237
+ throw new TypeError('Hagilight rss options must be an object.');
238
+ }
239
+ if (options.rss?.enabled !== undefined && typeof options.rss.enabled !== 'boolean') {
240
+ throw new TypeError('Hagilight rss enabled option must be a boolean.');
241
+ }
242
+ const rssOptions = resolveRssOptions(options.rss);
213
243
  if (options.analytics !== undefined
214
244
  && (!options.analytics || typeof options.analytics !== 'object' || Array.isArray(options.analytics))) {
215
245
  throw new TypeError('Hagilight analytics options must be an object.');
@@ -271,7 +301,7 @@ export default function hagilight(options = {}) {
271
301
  return {
272
302
  name: '@hagicode/hagilight-starlight',
273
303
  hooks: {
274
- 'config:setup'({ config, updateConfig, addIntegration }) {
304
+ 'config:setup'({ astroConfig, config, updateConfig, addIntegration }) {
275
305
  if (headerEnabled && config.components?.Header) {
276
306
  throw new Error('Hagilight cannot replace an existing Starlight Header override. Set header: { enabled: false } to keep it, or compose @hagicode/hagilight-starlight/Header directly.');
277
307
  }
@@ -291,17 +321,31 @@ export default function hagilight(options = {}) {
291
321
  throw new Error('Hagilight Google Analytics cannot replace an existing Starlight Head override. Compose @hagicode/hagilight/GoogleAnalytics in your Head instead.');
292
322
  }
293
323
 
324
+ const generateRss = options.rss?.enabled !== false && !getRssFeedUrl(config);
325
+ if (generateRss && !astroConfig.site) {
326
+ throw new Error('Hagilight RSS requires the Astro site option. Set site or disable RSS with rss: { enabled: false }.');
327
+ }
328
+ const rssLocales = generateRss ? resolveRssLocales(config.locales) : [];
329
+ const rssFeedUrl = generateRss
330
+ ? new URL('rss.xml', new URL((astroConfig.base ?? '/').replace(/\/?$/u, '/'), astroConfig.site)).toString()
331
+ : undefined;
294
332
  addIntegration(createConfiguredIntegration(
295
333
  instanceId,
296
334
  serializedOptions,
335
+ { options: rssOptions, locales: rssLocales },
297
336
  componentIds,
298
- () => getRssFeedUrl(config),
337
+ () => getRssFeedUrl(config) ?? rssFeedUrl,
338
+ generateRss,
299
339
  ));
300
340
  updateConfig({
301
341
  ...(config.logo === undefined ? { logo: { src: defaultLogo, alt: 'HagiCode' } } : {}),
302
342
  customCss: [...(config.customCss ?? []), contentWidthCssPath],
303
343
  head: [
304
344
  ...(config.head ?? []),
345
+ ...(rssFeedUrl ? [{
346
+ tag: 'link',
347
+ attrs: { rel: 'alternate', type: 'application/rss+xml', href: rssFeedUrl },
348
+ }] : []),
305
349
  { tag: 'script', content: contentWidthHeadScript },
306
350
  ],
307
351
  components: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hagicode/hagilight-starlight",
3
- "version": "0.2.2",
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,11 +31,13 @@
30
31
  "index.mjs",
31
32
  "*.css",
32
33
  "*.mjs",
34
+ "rss*.xml.ts",
33
35
  "*.astro",
34
36
  "assets/*.png"
35
37
  ],
36
38
  "dependencies": {
37
- "@hagicode/hagilight": "0.2.2"
39
+ "@astrojs/rss": "^4.0.19",
40
+ "@hagicode/hagilight": "0.2.3-dev.23.1.f56e71c"
38
41
  },
39
42
  "peerDependencies": {
40
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 ADDED
@@ -0,0 +1,15 @@
1
+ import { getCollection } from 'astro:content';
2
+ import type { APIRoute } from 'astro';
3
+ import rssConfig from 'virtual:hagilight-starlight/rss-config';
4
+ import { generateRssFeed } from './rss-renderer.mjs';
5
+
6
+ export const GET: APIRoute = async ({ site }) => {
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,
14
+ });
15
+ };