@hagicode/hagilight 0.1.0-dev.148.1.e0c630e

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 ADDED
@@ -0,0 +1,15 @@
1
+ ---
2
+ import type { ComponentProps } from 'astro/types';
3
+ import manifest from './package.json';
4
+ import CoreFooter from '@hagicode/hagilight-core/Footer';
5
+
6
+ interface Props extends ComponentProps<typeof CoreFooter> {}
7
+
8
+ if (typeof manifest.version !== 'string' || manifest.version.trim().length === 0) {
9
+ throw new Error('@hagicode/hagilight requires a nonempty string version in its package.json.');
10
+ }
11
+ ---
12
+
13
+ <CoreFooter {...Astro.props}>
14
+ <span slot="attribution">Powered By hagilight@{manifest.version}</span>
15
+ </CoreFooter>
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # @hagicode/hagilight
2
+
3
+ Astro integrations and components for HagiCode sites without Starlight. Provides sitemap and `robots.txt` generation, localized RSS feeds (enabled by default) and favicon, and a reusable SEO head component.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @hagicode/hagilight astro
9
+ ```
10
+
11
+ Use a supported Astro version (`>=7`). The matching `@hagicode/hagilight-core` version is installed as a dependency.
12
+
13
+ ## Use
14
+
15
+ ```js
16
+ // astro.config.mjs
17
+ import { defineConfig } from 'astro/config';
18
+ import { hagilight, hagilightFavicon } from '@hagicode/hagilight/integration';
19
+
20
+ export default defineConfig({
21
+ site: 'https://example.com',
22
+ integrations: [hagilight(), hagilightFavicon()],
23
+ });
24
+ ```
25
+
26
+ `hagilight()` generates a sitemap and `robots.txt` when the site does not already provide them, and localized RSS feeds (`/rss.xml`, `/rss.<language>.xml`) by default. Set an absolute HTTP(S) `site` URL. `hagilightFavicon()` adds the bundled icon unless the site already declares one.
27
+
28
+ RSS is on by default: `hagilight()` derives feed locales from the Astro `i18n` config and serves a built-in empty feed. Customize it with the `rss` option, for example `hagilight({ rss: { getFeed: './src/rss-feed.ts' } })`; `getFeed` points to a project-root-relative module exporting a feed callback. Pass `rss: false` to disable RSS. Import `@hagicode/hagilight/SEOHead` for SEO metadata in an Astro page or layout.
29
+
30
+ Use `@hagicode/hagilight/Footer` for the shared localized footer with the
31
+ literal `Powered By hagilight@<version>` attribution. The version is read at build
32
+ time from this package's own manifest, not from Astro. Existing
33
+ `@hagicode/hagilight-core/Footer` imports remain supported and render without a
34
+ feature-package attribution; switch that import to `@hagicode/hagilight/Footer`
35
+ to opt in while keeping the same `locale` and `links` props.
36
+
37
+ ## Build line
38
+
39
+ `hagilight()` adds a build line to the footer's copyright text, after the attribution: `Branch:`, `Commit:` (seven characters, with the full hash in the `title` attribute), and `Built:` (UTC time to the second). The line appears even with `hagilight({ enabled: false })`. Without this integration, `@hagicode/hagilight-core/Footer` renders no build text.
40
+
41
+ The values come from `HAGILIGHT_BUILD_BRANCH` and `HAGILIGHT_BUILD_COMMIT`, then Git, then `unknown`; the core README gives the full order. CI systems often check out a detached HEAD, which reports `Branch: unknown`. Pass the branch in the build step:
42
+
43
+ ```yaml
44
+ - name: Build site
45
+ env:
46
+ HAGILIGHT_BUILD_BRANCH: ${{ github.head_ref || github.ref_name }}
47
+ run: npm run build
48
+ ```
49
+
50
+ `github.head_ref` is set on pull requests and `github.ref_name` on pushes. A shallow clone still reports its commit, because `git rev-parse HEAD` reads the checkout.
51
+
52
+ ## llms.txt
53
+
54
+ `hagilight()` writes `/llms.txt` after the build, following the [llms.txt](https://llmstxt.org/) layout: the site name as the H1, the home page description as a blockquote summary, and one H2 section of links per top-level path segment. It is generated from the built HTML, so it works for plain Astro and Starlight sites alike and needs no content collection.
55
+
56
+ - Only one language is listed: the Astro `i18n.defaultLocale`, then the home page language. The home pages of the other languages go under `## Optional`.
57
+ - Pages are left out when they are `noindex`, instant redirects, 404 pages, or declare a canonical URL for another page.
58
+ - Each link carries the page's meta description. A description repeated on three or more pages is treated as a site-wide default and left out of the links.
59
+ - Section order is Overview first, then by page count. A section lists 12 links (Overview 36) and the file lists at most 120.
60
+ - An `llms.txt` that the site already provides, as a public file or a page, is never overwritten. `hagilight({ enabled: false })` skips generation too.
61
+
62
+ ```js
63
+ hagilight({
64
+ llms: {
65
+ title: 'Example Docs', // default: the home page og:site_name
66
+ summary: 'What this site is.', // default: the home page description
67
+ lang: 'en-US', // default: i18n.defaultLocale
68
+ maxLinksPerSection: 60, // default: 12
69
+ maxLinks: 200, // default: 120
70
+ },
71
+ });
72
+ ```
73
+
74
+ Pass `llms: false` (or `{ enabled: false }`) to opt out. The file is only as good as the page titles and descriptions it reads, so write a real `description` for important pages.
75
+
76
+ ## Site metadata
77
+
78
+ `hagilight()` writes `/.well-known/hagilight.metadata.json` after the build, next to `llms.txt`. A consumer that needs one machine-readable description of the site reads this file. The schema and its privacy rules are in `@hagicode/hagilight-core`'s README.
79
+
80
+ ```json
81
+ {
82
+ "schemaVersion": 1,
83
+ "generator": { "name": "@hagicode/hagilight-starlight", "version": "0.1.0" },
84
+ "site": {
85
+ "name": "HagiCode Docs",
86
+ "url": "https://docs.hagicode.com/",
87
+ "base": "/",
88
+ "description": "Documentation for HagiCode."
89
+ },
90
+ "languages": {
91
+ "default": "en-US",
92
+ "locales": [
93
+ { "code": "en-US", "lang": "en-US", "path": "/" },
94
+ { "code": "zh-CN", "lang": "zh-CN", "path": "/zh-CN/" }
95
+ ]
96
+ },
97
+ "build": {
98
+ "branch": "main",
99
+ "commit": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
100
+ "builtAt": "2026-10-17T08:12:34Z"
101
+ },
102
+ "discovery": {
103
+ "sitemap": "/sitemap-index.xml",
104
+ "robots": "/robots.txt",
105
+ "rss": "/rss.xml",
106
+ "llms": "/llms.txt"
107
+ },
108
+ "analytics": { "googleAnalytics": true, "fiftyOneLa": false, "posthog": true },
109
+ "features": { "seo": true, "promoto": true }
110
+ }
111
+ ```
112
+
113
+ - The file is served under the site's base path, at `<site URL><base>.well-known/hagilight.metadata.json`. A site under `/docs/` gets `/docs/.well-known/hagilight.metadata.json`. An origin-level `/.well-known/` path needs a proxy or a second deployment; a hagilight build controls only its own output.
114
+ - Name and description come from the `name` and `description` options, then the home page `og:site_name` or title and description. Locales come from Astro `i18n`. Analytics flags are `true` only when the built pages contain that provider's loader.
115
+ - The file needs an absolute `site` URL. Without one, the build warns and skips the file; it does not fail.
116
+ - Sitemap and `llms.txt` are checked after they are written, so `discovery` lists what this build actually produced.
117
+ - Pass `metadata: false` to opt out, or `metadata: { name, description }` to set the values. The `enabled` option does not turn it off. A `public/.well-known/hagilight.metadata.json` file is copied as the site's own and kept byte for byte.
118
+ - Hosts that drop dot-directories need an explicit opt-in. GitHub Pages' Jekyll step skips `.well-known` unless the published root contains an empty `.nojekyll` file.
119
+ - When the Starlight plugin is also registered with metadata enabled, the Starlight plugin writes the file, and this integration defers to it. Registration order does not change the output.
120
+
121
+ ## Body images
122
+
123
+ `hagilight()` registers the content-image plugin from `@hagicode/hagilight-core/content-images`. Markdown and MDX body images then get `loading="lazy"`, `decoding="async"`, and the class `hagilight-content-image`, with the author's values kept. This registration does not depend on `enabled`, so `hagilight({ enabled: false })` still registers the plugin.
124
+
125
+ Local body images in `.md` and `.mdx` files are also wrapped in a `<picture>` element with AVIF and WebP sources. The rule is the same for both file types: a relative local image that is not `.svg` or `.gif` and does not resolve into `public/` is wrapped, so a Markdown image in an `.mdx` page renders as `picture` with an inner `<img>`. This needs an image service that can encode AVIF: the default sharp service does, and the passthrough no-op service skips the wrapper with one warning. Site-root and remote images, and raw HTML `<img>` tags, are not wrapped. Encoding adds build time, which Astro caches. In `.md`, each `<source>` also carries `src`, `loading`, `decoding`, `width`, and `height` from Astro's output; in `.mdx`, `<source>` carries only `srcset` and `type`. Browsers ignore the extra attributes on `source`, but they are not valid HTML there. The opt-out below turns this step off too.
126
+
127
+ To opt out, pass either form:
128
+
129
+ ```js
130
+ hagilight({ contentImages: false });
131
+ hagilight({ contentImages: { enabled: false } });
132
+ ```
133
+
134
+ Sites that do not use this integration can import the plugin from `@hagicode/hagilight-core` directly; its README describes the options and processor setup.
135
+
136
+ See the [repository documentation](https://github.com/HagiCode-org/hagilight#readme) for RSS configuration, feed callback types, and integration behavior.
package/SEOHead.astro ADDED
@@ -0,0 +1,54 @@
1
+ ---
2
+ import {
3
+ composeCanonicalSeoHead,
4
+ resolveSeoMetadata,
5
+ serializeJsonLd,
6
+ type HeadEntry,
7
+ } from '@hagicode/hagilight-core/seo';
8
+
9
+ interface Props {
10
+ page?: {
11
+ url?: string | URL;
12
+ title?: string;
13
+ description?: string;
14
+ image?: string;
15
+ };
16
+ site?: string | URL;
17
+ siteSeo?: {
18
+ title?: string;
19
+ description?: string;
20
+ image?: string;
21
+ };
22
+ basePath?: string;
23
+ head?: HeadEntry[];
24
+ structuredData?: Record<string, unknown> | Record<string, unknown>[];
25
+ }
26
+
27
+ const {
28
+ page = {},
29
+ site: configuredSite = Astro.site,
30
+ siteSeo = {},
31
+ basePath = import.meta.env.BASE_URL,
32
+ head = [],
33
+ structuredData = [],
34
+ } = Astro.props;
35
+ const site = configuredSite?.toString();
36
+ const currentPageUrl = site
37
+ ? new URL(`${Astro.url.pathname}${Astro.url.search}${Astro.url.hash}`, site).href
38
+ : undefined;
39
+ const metadata = {
40
+ ...resolveSeoMetadata({ pageSeo: page, siteSeo, site, basePath }),
41
+ site,
42
+ };
43
+ const canonicalUrl = page.url?.toString() ?? currentPageUrl;
44
+ const composedHead = composeCanonicalSeoHead(head, metadata, {
45
+ canonicalUrl,
46
+ explicitHead: [head],
47
+ });
48
+ const dataValues = Array.isArray(structuredData) ? structuredData : [structuredData];
49
+ ---
50
+
51
+ {composedHead.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)}
52
+ {dataValues.map((value) => (
53
+ <script type="application/ld+json" set:html={serializeJsonLd(value)} />
54
+ ))}
@@ -0,0 +1,2 @@
1
+ import type { MiddlewareHandler } from 'astro';
2
+ export declare const onRequest: MiddlewareHandler;
@@ -0,0 +1,5 @@
1
+ import buildInfo from 'virtual:hagilight/build-info';
2
+ export const onRequest = (context, next) => {
3
+ context.locals.hagilightBuild = buildInfo;
4
+ return next();
5
+ };
@@ -0,0 +1,79 @@
1
+ import type { AstroIntegration } from 'astro';
2
+ import { type FaviconOptions } from '@hagicode/hagilight-core/favicon';
3
+ import { type RssLocalesInput } from '@hagicode/hagilight-core/rss';
4
+ export type { RssFeedCallback, RssFeedContent, RssFeedRequest, RssFooterContext, } from './rss-config.js';
5
+ export interface HagilightOptions {
6
+ /** Generate `sitemap-index.xml` and `robots.txt`. Defaults to `true`. */
7
+ enabled?: boolean;
8
+ /**
9
+ * Generate localized `/rss.xml` and `/rss.<language>.xml` feeds. Defaults to
10
+ * `true`, so integrating `hagilight()` is enough to publish an RSS feed.
11
+ * Pass `false` to disable, or an options object to customize the feed:
12
+ * - `locales`: Starlight-shaped locale map. Derived from the Astro `i18n`
13
+ * config when omitted.
14
+ * - `getFeed`: project-root-relative module whose default export is an
15
+ * `RssFeedCallback`. A built-in empty feed is used when omitted.
16
+ */
17
+ rss?: boolean | HagilightRssOptions;
18
+ /**
19
+ * Apply the body-image baseline (`loading`, `decoding`, marker class) to Markdown
20
+ * and MDX images. Defaults to `true`; `false` or `{ enabled: false }` opts out.
21
+ * Independent of `enabled`, which controls only sitemap and robots.
22
+ */
23
+ contentImages?: boolean | HagilightContentImagesOptions;
24
+ /**
25
+ * Generate `/llms.txt` after the build from the built pages. Defaults to `true`; `false` or
26
+ * `{ enabled: false }` opts out. A site-provided `llms.txt` (public file or page) is never
27
+ * overwritten. It also follows `enabled`: `hagilight({ enabled: false })` skips it.
28
+ */
29
+ llms?: boolean | HagilightLlmsOptions;
30
+ /**
31
+ * Generate `/.well-known/hagilight.metadata.json` after the build. Defaults to `true`; `false`
32
+ * opts out, and an options object may set `name` and `description`. A site-provided file is
33
+ * never overwritten. Independent of `enabled`. When the Starlight plugin is also registered
34
+ * and generates metadata, the Starlight plugin writes the file.
35
+ */
36
+ metadata?: boolean | HagilightMetadataOptions;
37
+ }
38
+ export interface HagilightMetadataOptions {
39
+ /** Defaults to `true`. */
40
+ enabled?: boolean;
41
+ /** Site name. Defaults to the home page `og:site_name` or title, then the site hostname. */
42
+ name?: string;
43
+ /** Site description. Defaults to the home page description. */
44
+ description?: string;
45
+ }
46
+ export interface HagilightLlmsOptions {
47
+ /** Defaults to `true`. */
48
+ enabled?: boolean;
49
+ /** Site name for the H1. Defaults to the home page `og:site_name` or title. */
50
+ title?: string;
51
+ /** Summary blockquote. Defaults to the home page description. */
52
+ summary?: string;
53
+ /** Language to list, for example `en-US`. Defaults to the Astro default locale, then the home page language. */
54
+ lang?: string;
55
+ /** Maximum links per section. Defaults to 12. */
56
+ maxLinksPerSection?: number;
57
+ /** Maximum links in total. Defaults to 120. */
58
+ maxLinks?: number;
59
+ }
60
+ export interface HagilightContentImagesOptions {
61
+ /** Defaults to `true`. */
62
+ enabled?: boolean;
63
+ }
64
+ export interface HagilightRssOptions {
65
+ /** Starlight-shaped locale map, for example `{ root: { lang: 'en-US' }, 'zh-CN': { lang: 'zh-CN' } }`. Derived from Astro `i18n` when omitted. */
66
+ locales?: RssLocalesInput;
67
+ /** Project-root-relative module whose default export is an `RssFeedCallback`. A built-in empty feed is used when omitted. */
68
+ getFeed?: string;
69
+ }
70
+ export type HagilightFaviconOptions = FaviconOptions;
71
+ /**
72
+ * Generate a sitemap and `robots.txt` for a plain Astro site, and (by default)
73
+ * localized RSS feeds. An existing `@astrojs/sitemap` or Starlight integration
74
+ * owns sitemap generation, a consumer-owned robots page or public file takes
75
+ * precedence, and an enabled Starlight Hagilight integration owns RSS feeds.
76
+ */
77
+ export declare function hagilight({ enabled, rss, contentImages, llms, metadata }?: HagilightOptions): AstroIntegration;
78
+ /** Inject the bundled HagiCode favicon unless the site head already declares an icon. */
79
+ export declare function hagilightFavicon(options?: HagilightFaviconOptions): AstroIntegration;
@@ -0,0 +1,400 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { extname, isAbsolute, join, resolve, win32 } from 'node:path';
4
+ import sitemap from '@astrojs/sitemap';
5
+ import { resolveBuildInfo } from '@hagicode/hagilight-core/build-info-resolver';
6
+ import { checkContentImageRegistration, registerContentImages } from '@hagicode/hagilight-core/content-images';
7
+ import { resolveFaviconHeadEntry } from '@hagicode/hagilight-core/favicon';
8
+ import { resolveRssLocales } from '@hagicode/hagilight-core/rss';
9
+ import { resolvePlainAstroRssOwner } from '@hagicode/hagilight-core/rss-ownership';
10
+ import { resolveSiteMetadataOption } from '@hagicode/hagilight-core/site-metadata';
11
+ import { expectSiteMetadataReport, reportSiteMetadataBuilt, resolveSiteMetadataOwner, } from '@hagicode/hagilight-core/site-metadata-ownership';
12
+ import { buildLlmsTxt, parsePageMeta } from './llms.js';
13
+ import { writeAstroSiteMetadata } from './site-metadata.js';
14
+ const VIRTUAL_RSS_CONFIG = 'virtual:hagilight/rss-config';
15
+ const VIRTUAL_RSS_CONFIG_RESOLVED = `\0${VIRTUAL_RSS_CONFIG}`;
16
+ const VIRTUAL_BUILD_INFO = 'virtual:hagilight/build-info';
17
+ const VIRTUAL_BUILD_INFO_RESOLVED = `\0${VIRTUAL_BUILD_INFO}`;
18
+ const PAGE_SOURCE_EXTENSIONS = new Set(['.astro', '.html', '.js', '.jsx', '.md', '.mdx', '.ts', '.tsx']);
19
+ const packageRoot = new URL('../', import.meta.url);
20
+ const routeEntrypoint = (name) => fileURLToPath(new URL(name, packageRoot));
21
+ function isRecord(value) {
22
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
23
+ }
24
+ function integrationName(integration) {
25
+ return isRecord(integration) ? integration.name : undefined;
26
+ }
27
+ function resolveDiscoverySite(site) {
28
+ const message = 'Hagilight sitemap and robots require an absolute HTTP(S) Astro site URL. Set `site` in astro.config.mjs.';
29
+ let parsed;
30
+ try {
31
+ parsed = new URL(site);
32
+ }
33
+ catch {
34
+ throw new Error(message);
35
+ }
36
+ if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname || parsed.username || parsed.password) {
37
+ throw new Error(message);
38
+ }
39
+ return parsed;
40
+ }
41
+ /**
42
+ * Built-in RSS feed used when a consumer does not supply a `getFeed` module.
43
+ * It is serialized into the generated virtual module, so it must stay a pure
44
+ * function with no external references.
45
+ */
46
+ const BUILTIN_RSS_FEED = async () => ({
47
+ title: 'Site Updates',
48
+ description: 'RSS feed for this site.',
49
+ items: [],
50
+ });
51
+ const BUILTIN_RSS_FEED_SOURCE = BUILTIN_RSS_FEED.toString();
52
+ /**
53
+ * Generate a sitemap and `robots.txt` for a plain Astro site, and (by default)
54
+ * localized RSS feeds. An existing `@astrojs/sitemap` or Starlight integration
55
+ * owns sitemap generation, a consumer-owned robots page or public file takes
56
+ * precedence, and an enabled Starlight Hagilight integration owns RSS feeds.
57
+ */
58
+ export function hagilight({ enabled = true, rss, contentImages, llms, metadata } = {}) {
59
+ if (typeof enabled !== 'boolean') {
60
+ throw new TypeError('Hagilight sitemap and robots enabled option must be a boolean.');
61
+ }
62
+ if (contentImages !== undefined && typeof contentImages !== 'boolean' && !isRecord(contentImages)) {
63
+ throw new TypeError('Hagilight content images option must be a boolean or an options object.');
64
+ }
65
+ if (isRecord(contentImages) && contentImages.enabled !== undefined && typeof contentImages.enabled !== 'boolean') {
66
+ throw new TypeError('Hagilight content images enabled option must be a boolean.');
67
+ }
68
+ const llmsOptions = resolveLlmsOptions(llms);
69
+ const metadataOptions = resolveSiteMetadataOption(metadata);
70
+ const contentImagesEnabled = typeof contentImages === 'boolean' ? contentImages : contentImages?.enabled !== false;
71
+ if (rss !== undefined && typeof rss !== 'boolean' && !isRecord(rss)) {
72
+ throw new TypeError('Hagilight RSS option must be a boolean or an options object.');
73
+ }
74
+ if (isRecord(rss)) {
75
+ if (rss.locales !== undefined && !isRecord(rss.locales)) {
76
+ throw new TypeError('Hagilight RSS locales must be an object.');
77
+ }
78
+ if (rss.getFeed !== undefined && (typeof rss.getFeed !== 'string' || !rss.getFeed.trim())) {
79
+ throw new TypeError('Hagilight RSS getFeed must be a non-empty module path.');
80
+ }
81
+ }
82
+ let resolvedConfig;
83
+ let resolvedBuildInfo;
84
+ // Set when the Starlight plugin owns the metadata file. This integration then reports
85
+ // after its `llms.txt` step, so the owner's write sees the final discovery outputs.
86
+ let metadataPeerToken;
87
+ return {
88
+ name: '@hagicode/hagilight:discovery',
89
+ hooks: {
90
+ 'astro:config:setup'({ config, injectRoute, addMiddleware, updateConfig, logger }) {
91
+ // Integrations added by this hook go in one update, in order: the sitemap first, then the
92
+ // metadata finalizer, so the finalizer's build hook runs after the sitemap has written its index.
93
+ let sitemapNeeded = false;
94
+ try {
95
+ registerContentImages({ config, logger, enabled: contentImagesEnabled });
96
+ const buildInfo = resolveBuildInfo({ root: fileURLToPath(config.root) });
97
+ resolvedBuildInfo = buildInfo;
98
+ metadataPeerToken = resolveSiteMetadataOwner() === 'starlight' ? expectSiteMetadataReport() : undefined;
99
+ updateConfig({ vite: { plugins: [createBuildInfoPlugin(buildInfo)] } });
100
+ addMiddleware({ entrypoint: fileURLToPath(new URL('./build-info-middleware.js', import.meta.url)), order: 'pre' });
101
+ if (!enabled)
102
+ return;
103
+ resolveDiscoverySite(config.site);
104
+ const integrations = config.integrations ?? [];
105
+ sitemapNeeded = !integrations.some((integration) => {
106
+ const name = integrationName(integration);
107
+ return name === '@astrojs/sitemap' || name === '@astrojs/starlight';
108
+ });
109
+ const pageRoot = resolve(fileURLToPath(config.srcDir), 'pages');
110
+ const publicRoot = fileURLToPath(config.publicDir);
111
+ const hasRobotsPage = existsSync(pageRoot) && readdirSync(pageRoot).some((name) => /^robots\.txt\.(?:astro|html|js|jsx|md|mdx|ts|tsx)$/u.test(name)
112
+ || name === 'robots.txt');
113
+ if (!hasRobotsPage && !existsSync(resolve(publicRoot, 'robots.txt'))) {
114
+ injectRoute({
115
+ pattern: '/robots.txt',
116
+ entrypoint: routeEntrypoint('robots.txt.ts'),
117
+ prerender: true,
118
+ });
119
+ }
120
+ if (rss === false)
121
+ return;
122
+ if (resolvePlainAstroRssOwner(config.integrations) === 'starlight')
123
+ return;
124
+ const rssOptions = rss === true || rss === undefined ? {} : rss;
125
+ const locales = resolveRssLocales(deriveRssLocales(rssOptions.locales, config), { requireNonEmpty: false });
126
+ const modulePath = rssOptions.getFeed
127
+ ? resolveFeedModule(config.root, rssOptions.getFeed)
128
+ : null;
129
+ const site = resolveSite(config.site);
130
+ assertNoRouteCollisions(config, locales);
131
+ const baseSegments = `${config.base || '/'}`.split('/').filter(Boolean);
132
+ const baseUrl = baseSegments.length > 0 ? `/${baseSegments.join('/')}/` : '/';
133
+ const baseLocation = new URL(baseUrl, site);
134
+ const defaultFeedUrl = new URL('rss.xml', baseLocation).href;
135
+ const localeFeedUrls = Object.fromEntries(locales
136
+ .filter(({ filename }) => filename !== 'en')
137
+ .map(({ lang, filename }) => [lang, new URL(`rss.${filename}.xml`, baseLocation).href]));
138
+ injectRoute({ pattern: '/rss.xml', entrypoint: routeEntrypoint('rss.xml.ts'), prerender: true });
139
+ injectRoute({ pattern: '/rss.[language].xml', entrypoint: routeEntrypoint('rss.[language].xml.ts'), prerender: true });
140
+ addMiddleware({ entrypoint: fileURLToPath(new URL('./rss-middleware.js', import.meta.url)), order: 'pre' });
141
+ updateConfig({
142
+ vite: {
143
+ plugins: [createVirtualPlugin({ modulePath, site, baseUrl, locales, defaultFeedUrl, localeFeedUrls })],
144
+ },
145
+ });
146
+ }
147
+ finally {
148
+ // The finalizer is skipped when a Starlight owner writes the file.
149
+ const appended = [
150
+ ...(sitemapNeeded ? [sitemap()] : []),
151
+ ...(metadataPeerToken === undefined && metadataOptions.enabled
152
+ ? [createMetadataFinalizer(() => ({ config: resolvedConfig, build: resolvedBuildInfo, options: metadataOptions }))]
153
+ : []),
154
+ ];
155
+ if (appended.length > 0)
156
+ updateConfig({ integrations: appended });
157
+ }
158
+ },
159
+ 'astro:config:done'({ config, logger }) {
160
+ resolvedConfig = config;
161
+ checkContentImageRegistration({ config, logger, enabled: contentImagesEnabled });
162
+ },
163
+ 'astro:build:done'({ dir, pages, logger }) {
164
+ try {
165
+ if (enabled && llmsOptions.enabled && resolvedConfig) {
166
+ writeLlmsTxt({ config: resolvedConfig, dir, pages, options: llmsOptions, logger });
167
+ }
168
+ }
169
+ finally {
170
+ if (metadataPeerToken !== undefined) {
171
+ reportSiteMetadataBuilt(metadataPeerToken);
172
+ metadataPeerToken = undefined;
173
+ }
174
+ }
175
+ },
176
+ },
177
+ };
178
+ }
179
+ /**
180
+ * Integration appended after the sitemap, so its `astro:build:done` runs after
181
+ * `@astrojs/sitemap` has written `sitemap-index.xml`. Hook order follows the
182
+ * integration list, which is why the metadata write does not run in this integration's own hook.
183
+ */
184
+ function createMetadataFinalizer(source) {
185
+ return {
186
+ name: '@hagicode/hagilight:metadata',
187
+ hooks: {
188
+ 'astro:build:done'({ dir, pages, logger }) {
189
+ const { config, build, options } = source();
190
+ if (!config || !build || !options.enabled)
191
+ return;
192
+ writeAstroSiteMetadata({ config, outDir: fileURLToPath(dir), pages, build, options, logger });
193
+ },
194
+ },
195
+ };
196
+ }
197
+ function resolveLlmsOptions(llms) {
198
+ if (llms !== undefined && typeof llms !== 'boolean' && !isRecord(llms)) {
199
+ throw new TypeError('Hagilight llms option must be a boolean or an options object.');
200
+ }
201
+ if (typeof llms === 'boolean')
202
+ return { enabled: llms };
203
+ const options = llms ?? {};
204
+ if (options.enabled !== undefined && typeof options.enabled !== 'boolean') {
205
+ throw new TypeError('Hagilight llms enabled option must be a boolean.');
206
+ }
207
+ for (const key of ['title', 'summary', 'lang']) {
208
+ if (options[key] !== undefined && (typeof options[key] !== 'string' || !options[key].trim())) {
209
+ throw new TypeError(`Hagilight llms ${key} must be a non-empty string.`);
210
+ }
211
+ }
212
+ for (const key of ['maxLinksPerSection', 'maxLinks']) {
213
+ if (options[key] !== undefined && (!Number.isInteger(options[key]) || options[key] < 1)) {
214
+ throw new TypeError(`Hagilight llms ${key} must be a positive integer.`);
215
+ }
216
+ }
217
+ return { ...options, enabled: options.enabled !== false };
218
+ }
219
+ function writeLlmsTxt({ config, dir, pages, options, logger }) {
220
+ const outDir = fileURLToPath(dir);
221
+ const target = join(outDir, 'llms.txt');
222
+ if (existsSync(target)) {
223
+ logger.info('llms.txt already exists in the build output; leaving it untouched.');
224
+ return;
225
+ }
226
+ const base = `${config.base || '/'}`.replace(/\/?$/u, '/');
227
+ const siteBase = new URL(base, config.site);
228
+ const i18n = config.i18n;
229
+ const entries = [];
230
+ for (const { pathname } of pages) {
231
+ const relative = pathname.replace(/^\/+/u, '');
232
+ const candidates = relative === '' || relative.endsWith('/')
233
+ ? [join(outDir, relative, 'index.html')]
234
+ : [join(outDir, relative, 'index.html'), join(outDir, `${relative}.html`)];
235
+ const file = candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
236
+ if (!file)
237
+ continue;
238
+ const url = new URL(relative, siteBase).href;
239
+ const meta = parsePageMeta(readFileSync(file, 'utf8'), url);
240
+ if (!meta)
241
+ continue;
242
+ entries.push({ path: new URL(url).pathname.slice(siteBase.pathname.length - 1), url, lang: meta.lang, title: meta.title, description: meta.description, siteName: meta.siteName });
243
+ }
244
+ const body = buildLlmsTxt({
245
+ pages: entries,
246
+ title: options.title,
247
+ summary: options.summary,
248
+ lang: options.lang ?? i18n?.defaultLocale,
249
+ maxLinksPerSection: options.maxLinksPerSection,
250
+ maxLinks: options.maxLinks,
251
+ });
252
+ if (!body) {
253
+ logger.warn('No indexable pages were found, so llms.txt was not generated.');
254
+ return;
255
+ }
256
+ writeFileSync(target, body, 'utf8');
257
+ logger.info(`Generated llms.txt from ${entries.length} page(s).`);
258
+ }
259
+ function deriveRssLocales(locales, config) {
260
+ if (locales !== undefined)
261
+ return locales;
262
+ const i18n = config.i18n;
263
+ if (!i18n?.locales?.length)
264
+ return undefined;
265
+ const defaultLocale = i18n.defaultLocale ?? String(i18n.locales[0]);
266
+ const map = {};
267
+ for (const entry of i18n.locales) {
268
+ const lang = typeof entry === 'string' ? entry : entry.codes?.[0]
269
+ ?? entry.path;
270
+ if (typeof lang !== 'string' || !lang.trim())
271
+ continue;
272
+ if (lang === defaultLocale)
273
+ map.root = { lang };
274
+ else
275
+ map[lang] = { lang };
276
+ }
277
+ return map;
278
+ }
279
+ function resolveFeedModule(root, reference) {
280
+ if (reference.startsWith('file:') || isAbsolute(reference) || win32.isAbsolute(reference)) {
281
+ throw new TypeError('Hagilight RSS getFeed must be a path relative to the Astro project root.');
282
+ }
283
+ const modulePath = resolve(fileURLToPath(root), reference);
284
+ if (!existsSync(modulePath) || !statSync(modulePath).isFile()) {
285
+ throw new Error(`Hagilight RSS getFeed module "${reference}" was not found relative to the Astro project root.`);
286
+ }
287
+ return modulePath;
288
+ }
289
+ function resolveSite(site) {
290
+ let parsed;
291
+ try {
292
+ parsed = new URL(site);
293
+ }
294
+ catch {
295
+ throw new Error('Hagilight RSS requires an absolute Astro site URL. Set `site` in astro.config.mjs.');
296
+ }
297
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
298
+ throw new Error('Hagilight RSS requires an absolute HTTP(S) Astro site URL.');
299
+ }
300
+ return parsed.href;
301
+ }
302
+ function assertNoRouteCollisions(config, locales) {
303
+ const expectedFiles = new Set(['rss.xml', 'rss.en.xml', ...locales.map(({ filename }) => `rss.${filename}.xml`)]);
304
+ const pageRoot = resolve(fileURLToPath(config.srcDir), 'pages');
305
+ const publicRoot = fileURLToPath(config.publicDir);
306
+ const collisions = [];
307
+ function findPageCollisions(directory) {
308
+ if (!existsSync(directory))
309
+ return;
310
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
311
+ const entryPath = resolve(directory, entry.name);
312
+ if (entry.isDirectory()) {
313
+ if (entry.name === 'rss.xml' || /^rss\..+\.xml$/u.test(entry.name)) {
314
+ for (const child of readdirSync(entryPath, { withFileTypes: true })) {
315
+ if (child.isFile()
316
+ && child.name.startsWith('index.')
317
+ && PAGE_SOURCE_EXTENSIONS.has(extname(child.name))) {
318
+ collisions.push(resolve(entryPath, child.name));
319
+ }
320
+ }
321
+ }
322
+ findPageCollisions(entryPath);
323
+ }
324
+ else if (entry.isFile()) {
325
+ const extension = extname(entry.name);
326
+ if (!PAGE_SOURCE_EXTENSIONS.has(extension))
327
+ continue;
328
+ const sourceName = entry.name.slice(0, -extension.length);
329
+ if (expectedFiles.has(sourceName) || /^rss\..+\.xml$/u.test(sourceName)) {
330
+ collisions.push(entryPath);
331
+ }
332
+ }
333
+ }
334
+ }
335
+ findPageCollisions(pageRoot);
336
+ for (const filename of expectedFiles) {
337
+ const publicFile = resolve(publicRoot, filename);
338
+ if (existsSync(publicFile) && statSync(publicFile).isFile())
339
+ collisions.push(publicFile);
340
+ }
341
+ if (collisions.length > 0) {
342
+ throw new Error(`Hagilight RSS route conflicts with consumer-owned file(s): ${collisions.join(', ')}. Remove the conflicting route or disable RSS with rss: false.`);
343
+ }
344
+ }
345
+ function createVirtualPlugin({ modulePath, site, baseUrl, locales, defaultFeedUrl, localeFeedUrls }) {
346
+ const feedImport = modulePath ? `import * as siteFeedModule from ${JSON.stringify(modulePath)};` : '';
347
+ const getFeedExpr = modulePath ? 'siteFeedModule.default' : `(${BUILTIN_RSS_FEED_SOURCE})`;
348
+ return {
349
+ name: '@hagicode/hagilight:rss',
350
+ resolveId(id) {
351
+ return id === VIRTUAL_RSS_CONFIG ? VIRTUAL_RSS_CONFIG_RESOLVED : null;
352
+ },
353
+ load(id) {
354
+ if (id !== VIRTUAL_RSS_CONFIG_RESOLVED)
355
+ return null;
356
+ return [
357
+ feedImport,
358
+ 'const config = {',
359
+ ` site: ${JSON.stringify(site)},`,
360
+ ` baseUrl: ${JSON.stringify(baseUrl)},`,
361
+ ` locales: ${JSON.stringify(locales)},`,
362
+ ` getFeed: ${getFeedExpr},`,
363
+ ` footer: ${JSON.stringify({ defaultFeedUrl, localeFeedUrls, locales })},`,
364
+ '};',
365
+ 'export default config;',
366
+ ].join('\n');
367
+ },
368
+ };
369
+ }
370
+ function createBuildInfoPlugin(buildInfo) {
371
+ return {
372
+ name: '@hagicode/hagilight:build-info',
373
+ resolveId(id) {
374
+ return id === VIRTUAL_BUILD_INFO ? VIRTUAL_BUILD_INFO_RESOLVED : null;
375
+ },
376
+ load(id) {
377
+ if (id !== VIRTUAL_BUILD_INFO_RESOLVED)
378
+ return null;
379
+ return `export default ${JSON.stringify(buildInfo)};`;
380
+ },
381
+ };
382
+ }
383
+ /** Inject the bundled HagiCode favicon unless the site head already declares an icon. */
384
+ export function hagilightFavicon(options = {}) {
385
+ if (!isRecord(options)) {
386
+ throw new TypeError('Hagilight favicon options must be an object.');
387
+ }
388
+ return {
389
+ name: '@hagicode/hagilight:favicon',
390
+ hooks: {
391
+ 'astro:config:setup'({ config, updateConfig }) {
392
+ const head = config.head;
393
+ const entry = resolveFaviconHeadEntry(head, options);
394
+ if (!entry)
395
+ return;
396
+ updateConfig({ head: [...(head ?? []), entry] });
397
+ },
398
+ },
399
+ };
400
+ }
package/dist/llms.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `llms.txt` generation. The file is derived from the built HTML so it works for every site
3
+ * type (plain Astro, Starlight) without depending on content collections: each indexable page
4
+ * contributes its `<title>` and meta description, grouped by its first path segment.
5
+ * Format: https://llmstxt.org/ (H1 name, blockquote summary, H2 link sections).
6
+ */
7
+ export interface LlmsPage {
8
+ /** Path relative to the site root, with a leading slash, for example `/guides/install/`. */
9
+ path: string;
10
+ url: string;
11
+ lang: string | undefined;
12
+ title: string;
13
+ description: string | undefined;
14
+ /** The page's `og:site_name`, used to name the site and to strip the title suffix. */
15
+ siteName?: string | undefined;
16
+ }
17
+ export interface LlmsPageMeta {
18
+ lang: string | undefined;
19
+ title: string;
20
+ description: string | undefined;
21
+ siteName: string | undefined;
22
+ }
23
+ export interface BuildLlmsTxtOptions {
24
+ pages: readonly LlmsPage[];
25
+ /** Overrides the site name taken from the home page. */
26
+ title?: string;
27
+ /** Overrides the summary taken from the home page description. */
28
+ summary?: string;
29
+ /** Preferred language, for example the Astro default locale. Falls back to the home page language. */
30
+ lang?: string;
31
+ maxLinksPerSection?: number;
32
+ maxLinks?: number;
33
+ }
34
+ export declare const DEFAULT_MAX_LINKS_PER_SECTION = 12;
35
+ export declare const DEFAULT_MAX_LINKS = 120;
36
+ /**
37
+ * Read the facts `llms.txt` needs from one built page. Returns `undefined` for pages that should
38
+ * not be listed: `noindex`, instant redirects, 404 pages, and pages whose canonical URL points
39
+ * somewhere else.
40
+ */
41
+ /**
42
+ * Read the page head without applying indexing rules. Site-level facts such as the
43
+ * site name and description stay available on pages that are `noindex`.
44
+ */
45
+ export declare function parseHeadMeta(html: string): LlmsPageMeta;
46
+ export declare function parsePageMeta(html: string, pageUrl: string): LlmsPageMeta | undefined;
47
+ /** Build the `llms.txt` body. Returns `undefined` when there is nothing to list. */
48
+ export declare function buildLlmsTxt(options: BuildLlmsTxtOptions): string | undefined;
package/dist/llms.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `llms.txt` generation. The file is derived from the built HTML so it works for every site
3
+ * type (plain Astro, Starlight) without depending on content collections: each indexable page
4
+ * contributes its `<title>` and meta description, grouped by its first path segment.
5
+ * Format: https://llmstxt.org/ (H1 name, blockquote summary, H2 link sections).
6
+ */
7
+ export const DEFAULT_MAX_LINKS_PER_SECTION = 12;
8
+ export const DEFAULT_MAX_LINKS = 120;
9
+ const OVERVIEW_LIMIT_FACTOR = 3;
10
+ const GENERIC_DESCRIPTION_MIN_PAGES = 3;
11
+ const LOCALE_SEGMENT = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/iu;
12
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
13
+ function decodeEntities(value) {
14
+ return value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/giu, (match, dec, hex, name) => {
15
+ if (dec)
16
+ return String.fromCodePoint(Number(dec));
17
+ if (hex)
18
+ return String.fromCodePoint(parseInt(hex, 16));
19
+ return ENTITIES[String(name).toLowerCase()] ?? match;
20
+ });
21
+ }
22
+ function attributes(tag) {
23
+ const result = {};
24
+ for (const [, name, double, single] of tag.matchAll(/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)) {
25
+ result[name.toLowerCase()] = decodeEntities(double ?? single ?? '');
26
+ }
27
+ return result;
28
+ }
29
+ function clean(value) {
30
+ const text = value?.replace(/\s+/gu, ' ').trim();
31
+ return text ? text : undefined;
32
+ }
33
+ function normalizeUrl(href, base) {
34
+ try {
35
+ const url = new URL(href, base);
36
+ url.hash = '';
37
+ url.search = '';
38
+ return url.href.replace(/\/index\.html$/u, '/');
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ /**
45
+ * Read the facts `llms.txt` needs from one built page. Returns `undefined` for pages that should
46
+ * not be listed: `noindex`, instant redirects, 404 pages, and pages whose canonical URL points
47
+ * somewhere else.
48
+ */
49
+ /**
50
+ * Read the page head without applying indexing rules. Site-level facts such as the
51
+ * site name and description stay available on pages that are `noindex`.
52
+ */
53
+ export function parseHeadMeta(html) {
54
+ const headEnd = html.indexOf('</head>');
55
+ const head = headEnd === -1 ? html : html.slice(0, headEnd);
56
+ const metas = [...head.matchAll(/<meta\b[^>]*>/giu)].map(([tag]) => attributes(tag));
57
+ const named = (name) => metas.find((meta) => meta.name?.toLowerCase() === name)?.content;
58
+ const property = (name) => metas.find((meta) => meta.property?.toLowerCase() === name)?.content;
59
+ const title = clean(decodeEntities(head.match(/<title[^>]*>([\s\S]*?)<\/title>/iu)?.[1] ?? '')) ?? '';
60
+ const htmlTag = html.match(/<html\b[^>]*>/iu)?.[0];
61
+ return {
62
+ lang: clean(htmlTag ? attributes(htmlTag).lang : undefined),
63
+ title,
64
+ description: clean(named('description') ?? property('og:description')),
65
+ siteName: clean(property('og:site_name')),
66
+ };
67
+ }
68
+ export function parsePageMeta(html, pageUrl) {
69
+ const headEnd = html.indexOf('</head>');
70
+ const head = headEnd === -1 ? html : html.slice(0, headEnd);
71
+ const metas = [...head.matchAll(/<meta\b[^>]*>/giu)].map(([tag]) => attributes(tag));
72
+ const named = (name) => metas.find((meta) => meta.name?.toLowerCase() === name)?.content;
73
+ if (/noindex/iu.test(named('robots') ?? ''))
74
+ return undefined;
75
+ if (metas.some((meta) => meta['http-equiv']?.toLowerCase() === 'refresh'))
76
+ return undefined;
77
+ const canonicalTag = [...head.matchAll(/<link\b[^>]*>/giu)]
78
+ .map(([tag]) => attributes(tag))
79
+ .find((link) => link.rel?.toLowerCase().split(/\s+/u).includes('canonical'));
80
+ if (canonicalTag?.href) {
81
+ const canonical = normalizeUrl(canonicalTag.href, pageUrl);
82
+ const own = normalizeUrl(pageUrl, pageUrl);
83
+ // A trailing-slash difference is a site setting, not a different page.
84
+ if (canonical && own && canonical.replace(/\/$/u, '') !== own.replace(/\/$/u, ''))
85
+ return undefined;
86
+ }
87
+ const meta = parseHeadMeta(html);
88
+ if (!meta.title || /^(?:404\b|page not found)/iu.test(meta.title))
89
+ return undefined;
90
+ return meta;
91
+ }
92
+ function sameLanguage(a, b) {
93
+ if (!a || !b)
94
+ return false;
95
+ const x = a.toLowerCase();
96
+ const y = b.toLowerCase();
97
+ return x === y || x.startsWith(`${y}-`) || y.startsWith(`${x}-`);
98
+ }
99
+ function segmentsOf(page) {
100
+ const segments = page.path.split('/').filter(Boolean);
101
+ if (segments.length > 0 && LOCALE_SEGMENT.test(segments[0]) && (sameLanguage(segments[0], page.lang) || segments.length === 1)) {
102
+ segments.shift();
103
+ }
104
+ return segments;
105
+ }
106
+ function humanize(segment) {
107
+ const text = decodeURIComponent(segment).replace(/[-_]+/gu, ' ').trim();
108
+ return text.replace(/\b[a-z]/gu, (letter) => letter.toUpperCase());
109
+ }
110
+ function stripSiteSuffix(title, siteName) {
111
+ if (!siteName)
112
+ return title;
113
+ const escaped = siteName.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
114
+ const stripped = title.replace(new RegExp(`\\s*[|•·–—-]\\s*${escaped}$`, 'iu'), '').trim();
115
+ return stripped || title;
116
+ }
117
+ function mostCommonLanguage(pages) {
118
+ const counts = new Map();
119
+ for (const page of pages)
120
+ if (page.lang)
121
+ counts.set(page.lang, (counts.get(page.lang) ?? 0) + 1);
122
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
123
+ }
124
+ function mostCommonSiteName(pages) {
125
+ const counts = new Map();
126
+ for (const page of pages)
127
+ if (page.siteName)
128
+ counts.set(page.siteName, (counts.get(page.siteName) ?? 0) + 1);
129
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
130
+ }
131
+ function linkLine(page, siteName, generic) {
132
+ const title = stripSiteSuffix(page.title, siteName).replace(/[[\]]/gu, '');
133
+ return page.description && !generic.has(page.description) ? `- [${title}](${page.url}): ${page.description}` : `- [${title}](${page.url})`;
134
+ }
135
+ /** Build the `llms.txt` body. Returns `undefined` when there is nothing to list. */
136
+ export function buildLlmsTxt(options) {
137
+ const maxPerSection = options.maxLinksPerSection ?? DEFAULT_MAX_LINKS_PER_SECTION;
138
+ const maxLinks = options.maxLinks ?? DEFAULT_MAX_LINKS;
139
+ const all = [...options.pages].sort((a, b) => a.path.localeCompare(b.path));
140
+ if (all.length === 0)
141
+ return undefined;
142
+ const shallowest = (candidates) => [...candidates].sort((a, b) => segmentsOf(a).length - segmentsOf(b).length || a.path.length - b.path.length)[0];
143
+ let lang = options.lang && all.some((page) => sameLanguage(page.lang, options.lang))
144
+ ? options.lang
145
+ : shallowest(all.filter((page) => segmentsOf(page).length === 0))?.lang ?? mostCommonLanguage(all);
146
+ if (!all.some((page) => sameLanguage(page.lang, lang)))
147
+ lang = mostCommonLanguage(all);
148
+ const inLanguage = all.filter((page) => lang === undefined || sameLanguage(page.lang, lang));
149
+ const home = shallowest(inLanguage.filter((page) => segmentsOf(page).length === 0)) ?? shallowest(inLanguage);
150
+ const siteName = clean(options.title) ?? home?.siteName ?? mostCommonSiteName(inLanguage) ?? home?.title;
151
+ const summary = clean(options.summary) ?? home?.description;
152
+ const sections = new Map();
153
+ const hasChildren = (segment) => inLanguage.some((other) => {
154
+ const parts = segmentsOf(other);
155
+ return parts[0] === segment && parts.length > 1;
156
+ });
157
+ for (const page of inLanguage) {
158
+ const segments = segmentsOf(page);
159
+ // Top-level pages without children (for example /about/) go to Overview; /guides/... goes to Guides.
160
+ const key = segments.length === 0 || (segments.length === 1 && !hasChildren(segments[0]))
161
+ ? 'Overview'
162
+ : humanize(segments[0]);
163
+ sections.set(key, [...(sections.get(key) ?? []), page]);
164
+ }
165
+ // A description repeated across pages is a site-wide default, not information about the page.
166
+ const descriptionCounts = new Map();
167
+ for (const page of inLanguage)
168
+ if (page.description)
169
+ descriptionCounts.set(page.description, (descriptionCounts.get(page.description) ?? 0) + 1);
170
+ const generic = new Set([...descriptionCounts].filter(([, count]) => count >= GENERIC_DESCRIPTION_MIN_PAGES).map(([text]) => text));
171
+ const lines = [`# ${siteName ?? 'Site'}`];
172
+ if (summary)
173
+ lines.push('', `> ${summary}`);
174
+ let remaining = maxLinks;
175
+ const ordered = [...sections.entries()].sort(([a, pa], [b, pb]) => (a === 'Overview' ? -1 : 0) - (b === 'Overview' ? -1 : 0) || pb.length - pa.length || a.localeCompare(b));
176
+ for (const [name, pages] of ordered) {
177
+ if (remaining <= 0)
178
+ break;
179
+ // Flat sites keep most pages under Overview, so it gets a larger share than a topical section.
180
+ const limit = name === 'Overview' ? maxPerSection * OVERVIEW_LIMIT_FACTOR : maxPerSection;
181
+ const picked = [...pages]
182
+ .sort((a, b) => segmentsOf(a).length - segmentsOf(b).length || a.path.localeCompare(b.path))
183
+ .slice(0, Math.min(limit, remaining));
184
+ remaining -= picked.length;
185
+ lines.push('', `## ${name}`, '', ...picked.map((page) => linkLine(page, siteName, generic)));
186
+ }
187
+ const alternates = all
188
+ .filter((page) => lang !== undefined && page.lang && !sameLanguage(page.lang, lang) && segmentsOf(page).length === 0)
189
+ .sort((a, b) => (a.lang ?? '').localeCompare(b.lang ?? ''));
190
+ if (alternates.length > 0) {
191
+ lines.push('', '## Optional', '', ...alternates.map((page) => `- [${page.lang}](${page.url})`));
192
+ }
193
+ return `${lines.join('\n')}\n`;
194
+ }
@@ -0,0 +1,28 @@
1
+ import type { RssFeedItem, RssLocale } from '@hagicode/hagilight-core/rss';
2
+ export interface RssFeedRequest {
3
+ /** Locale route key from the configured `locales` map (`root` for the default locale). */
4
+ route: string;
5
+ /** Canonical language tag of the requested feed. */
6
+ lang: string;
7
+ }
8
+ export interface RssFeedContent {
9
+ title: string;
10
+ description: string;
11
+ items: readonly RssFeedItem[];
12
+ }
13
+ /** Default export of the module referenced by `hagilight({ rss: { getFeed } })`. */
14
+ export type RssFeedCallback = (request: RssFeedRequest) => RssFeedContent | Promise<RssFeedContent>;
15
+ /** Request-local feed URLs that the plain-Astro Footer reads from `Astro.locals.hagilightRss`. */
16
+ export interface RssFooterContext {
17
+ defaultFeedUrl: string;
18
+ localeFeedUrls: Readonly<Record<string, string>>;
19
+ locales: readonly RssLocale[];
20
+ }
21
+ /** Serialized configuration exposed to generated routes through `virtual:hagilight/rss-config`. */
22
+ export interface RssRuntimeConfig {
23
+ site: string;
24
+ baseUrl: string;
25
+ locales: readonly RssLocale[];
26
+ getFeed: RssFeedCallback | undefined;
27
+ footer: RssFooterContext;
28
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { MiddlewareHandler } from 'astro';
2
+ export declare const onRequest: MiddlewareHandler;
@@ -0,0 +1,5 @@
1
+ import rssConfig from 'virtual:hagilight/rss-config';
2
+ export const onRequest = (context, next) => {
3
+ context.locals.hagilightRss = rssConfig.footer;
4
+ return next();
5
+ };
@@ -0,0 +1,2 @@
1
+ import type { RssRuntimeConfig } from './rss-config.js';
2
+ export declare function renderRssFeed(config: Pick<RssRuntimeConfig, 'site' | 'baseUrl' | 'locales' | 'getFeed'>, filename: string | undefined): Promise<Response>;
@@ -0,0 +1,44 @@
1
+ import { generateRssFeed } from '@hagicode/hagilight-core/rss';
2
+ const EMPTY_ENGLISH_FEED = {
3
+ title: 'Hagilight RSS Feed',
4
+ description: 'English-language feed for this site.',
5
+ items: [],
6
+ };
7
+ export async function renderRssFeed(config, filename) {
8
+ const locale = config.locales.find((entry) => entry.filename === filename);
9
+ if (!locale && filename !== 'en') {
10
+ throw new Error(`Hagilight RSS has no configured locale for "${filename}".`);
11
+ }
12
+ let feed;
13
+ if (!locale) {
14
+ feed = EMPTY_ENGLISH_FEED;
15
+ }
16
+ else {
17
+ if (typeof config.getFeed !== 'function') {
18
+ throw new TypeError('Hagilight RSS getFeed module must default-export a callback function.');
19
+ }
20
+ const result = await config.getFeed({ route: locale.route, lang: locale.lang });
21
+ if (!result || typeof result !== 'object' || Array.isArray(result)) {
22
+ throw new TypeError(`Hagilight RSS getFeed callback for "${locale.lang}" must return an object.`);
23
+ }
24
+ const candidate = result;
25
+ if (typeof candidate.title !== 'string' || !candidate.title.trim()) {
26
+ throw new TypeError(`Hagilight RSS getFeed callback for "${locale.lang}" must return a non-empty title.`);
27
+ }
28
+ if (typeof candidate.description !== 'string' || !candidate.description.trim()) {
29
+ throw new TypeError(`Hagilight RSS getFeed callback for "${locale.lang}" must return a non-empty description.`);
30
+ }
31
+ if (!Array.isArray(candidate.items)) {
32
+ throw new TypeError(`Hagilight RSS getFeed callback for "${locale.lang}" must return an items array.`);
33
+ }
34
+ feed = candidate;
35
+ }
36
+ return generateRssFeed({
37
+ site: config.site,
38
+ baseUrl: config.baseUrl,
39
+ language: locale?.lang ?? 'en',
40
+ title: feed.title,
41
+ description: feed.description,
42
+ items: feed.items,
43
+ });
44
+ }
@@ -0,0 +1,31 @@
1
+ import type { BuildInfo } from '@hagicode/hagilight-core/build-info';
2
+ import { type SiteMetadataLocale } from '@hagicode/hagilight-core/site-metadata';
3
+ import { type SiteMetadataLogger } from '@hagicode/hagilight-core/site-metadata-writer';
4
+ export interface WriteAstroSiteMetadataInput {
5
+ config: {
6
+ site?: unknown;
7
+ base?: string;
8
+ publicDir: URL;
9
+ i18n?: unknown;
10
+ };
11
+ outDir: string;
12
+ pages: readonly {
13
+ pathname: string;
14
+ }[];
15
+ build: BuildInfo;
16
+ options: {
17
+ name?: string;
18
+ description?: string;
19
+ };
20
+ logger: SiteMetadataLogger;
21
+ }
22
+ /** Map Astro `i18n` to locales relative to the base. Mirrors the RSS locale derivation. */
23
+ export declare function deriveAstroMetadataLocales(i18n: unknown): {
24
+ locales: SiteMetadataLocale[];
25
+ defaultLocale?: string;
26
+ };
27
+ /**
28
+ * Build and write the metadata file for a plain-Astro build. Skips with a warning
29
+ * when no absolute site URL is configured, because the file has to name the site.
30
+ */
31
+ export declare function writeAstroSiteMetadata({ config, outDir, pages, build, options, logger }: WriteAstroSiteMetadataInput): void;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Facts for `.well-known/hagilight.metadata.json` from a plain-Astro build. Site
3
+ * name and description come from the built home page, locales from Astro `i18n`,
4
+ * and analytics flags from the loaders present in the built pages.
5
+ */
6
+ import { existsSync, readFileSync, statSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { buildSiteMetadata, detectAnalyticsLoaders, SITE_METADATA_PATH, } from '@hagicode/hagilight-core/site-metadata';
10
+ import { findDiscoveryOutputs, writeSiteMetadata } from '@hagicode/hagilight-core/site-metadata-writer';
11
+ import { parseHeadMeta } from './llms.js';
12
+ const ASTRO_PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
13
+ function isRecord(value) {
14
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
15
+ }
16
+ function absoluteSite(site) {
17
+ try {
18
+ const parsed = new URL(site);
19
+ return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password ? parsed.href : undefined;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ /** Read the built HTML of each page that the build emitted, using the same lookup as `llms.txt`. */
26
+ function builtPageHtml(outDir, pages) {
27
+ const html = [];
28
+ for (const { pathname } of pages) {
29
+ const relative = pathname.replace(/^\/+/u, '');
30
+ const candidates = relative === '' || relative.endsWith('/')
31
+ ? [join(outDir, relative, 'index.html')]
32
+ : [join(outDir, relative, 'index.html'), join(outDir, `${relative}.html`)];
33
+ const file = candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
34
+ if (file)
35
+ html.push(readFileSync(file, 'utf8'));
36
+ }
37
+ return html;
38
+ }
39
+ function localeEntryKey(entry) {
40
+ if (typeof entry === 'string')
41
+ return entry.trim() || undefined;
42
+ if (isRecord(entry) && typeof entry.path === 'string' && entry.path.trim())
43
+ return entry.path.trim();
44
+ return undefined;
45
+ }
46
+ /** Map Astro `i18n` to locales relative to the base. Mirrors the RSS locale derivation. */
47
+ export function deriveAstroMetadataLocales(i18n) {
48
+ if (!isRecord(i18n) || !Array.isArray(i18n.locales) || i18n.locales.length === 0)
49
+ return { locales: [] };
50
+ const defaultLocale = typeof i18n.defaultLocale === 'string'
51
+ ? i18n.defaultLocale
52
+ : localeEntryKey(i18n.locales[0]);
53
+ const prefixDefaultLocale = isRecord(i18n.routing) && i18n.routing.prefixDefaultLocale === true;
54
+ const locales = [];
55
+ for (const entry of i18n.locales) {
56
+ const code = localeEntryKey(entry);
57
+ if (!code)
58
+ continue;
59
+ const codes = isRecord(entry) && Array.isArray(entry.codes) ? entry.codes : [];
60
+ const lang = typeof codes[0] === 'string' && codes[0].trim() ? codes[0].trim() : code;
61
+ const isDefault = code === defaultLocale;
62
+ locales.push({ code, lang, path: isDefault && !prefixDefaultLocale ? '/' : `/${code}/` });
63
+ }
64
+ return { locales, ...(defaultLocale === undefined ? {} : { defaultLocale }) };
65
+ }
66
+ /**
67
+ * Build and write the metadata file for a plain-Astro build. Skips with a warning
68
+ * when no absolute site URL is configured, because the file has to name the site.
69
+ */
70
+ export function writeAstroSiteMetadata({ config, outDir, pages, build, options, logger }) {
71
+ const siteUrl = absoluteSite(config.site);
72
+ if (!siteUrl) {
73
+ logger.warn(`${SITE_METADATA_PATH} requires an absolute Astro site URL, so it was not generated.`);
74
+ return;
75
+ }
76
+ const base = `${config.base || '/'}`.replace(/\/?$/u, '/');
77
+ const siteBase = new URL(base, siteUrl);
78
+ const homeFile = join(outDir, 'index.html');
79
+ const homeHtml = existsSync(homeFile) ? readFileSync(homeFile, 'utf8') : undefined;
80
+ // Site-level facts are read even when the home page is noindex, such as a root redirect page.
81
+ const homeMeta = homeHtml === undefined ? undefined : parseHeadMeta(homeHtml);
82
+ const pageHtml = builtPageHtml(outDir, pages);
83
+ if (homeHtml !== undefined && !pageHtml.includes(homeHtml))
84
+ pageHtml.push(homeHtml);
85
+ const locales = deriveAstroMetadataLocales(config.i18n);
86
+ const metadata = buildSiteMetadata({
87
+ generator: { name: '@hagicode/hagilight', version: ASTRO_PACKAGE_VERSION },
88
+ site: {
89
+ name: options.name ?? homeMeta?.siteName ?? homeMeta?.title,
90
+ url: siteUrl,
91
+ base,
92
+ description: options.description ?? homeMeta?.description,
93
+ },
94
+ locales: locales.locales,
95
+ defaultLocale: locales.defaultLocale,
96
+ fallbackLang: homeMeta?.lang,
97
+ build,
98
+ discovery: findDiscoveryOutputs(outDir),
99
+ analytics: detectAnalyticsLoaders(pageHtml),
100
+ });
101
+ writeSiteMetadata({
102
+ outDir,
103
+ metadata,
104
+ siteOwnedFiles: [join(fileURLToPath(config.publicDir), SITE_METADATA_PATH)],
105
+ logger,
106
+ });
107
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@hagicode/hagilight",
3
+ "version": "0.1.0-dev.148.1.e0c630e",
4
+ "type": "module",
5
+ "description": "Astro integrations and components for HagiCode sites",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/HagiCode-org/hagilight.git",
9
+ "directory": "packages/astro"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "provenance": true
14
+ },
15
+ "scripts": {
16
+ "build": "tsc -b",
17
+ "prepack": "tsc -b"
18
+ },
19
+ "exports": {
20
+ "./integration": {
21
+ "types": "./dist/integration.d.ts",
22
+ "default": "./dist/integration.js"
23
+ },
24
+ "./SEOHead": "./SEOHead.astro",
25
+ "./Footer": "./Footer.astro"
26
+ },
27
+ "files": [
28
+ "README.md",
29
+ "dist/**/*.js",
30
+ "dist/**/*.d.ts",
31
+ "*.astro",
32
+ "robots.txt.ts",
33
+ "rss*.xml.ts"
34
+ ],
35
+ "peerDependencies": {
36
+ "astro": ">=7"
37
+ },
38
+ "devDependencies": {
39
+ "astro": "^7.3.5"
40
+ },
41
+ "dependencies": {
42
+ "@astrojs/sitemap": "^3.7.4",
43
+ "@hagicode/hagilight-core": "0.1.0-dev.148.1.e0c630e"
44
+ },
45
+ "keywords": [
46
+ "hagicode",
47
+ "hagicode-org",
48
+ "hagilight",
49
+ "astro-component",
50
+ "astro-integration",
51
+ "withastro",
52
+ "astro-components",
53
+ "documentation-components",
54
+ "seo"
55
+ ],
56
+ "homepage": "https://github.com/HagiCode-org/hagilight#readme",
57
+ "bugs": {
58
+ "url": "https://github.com/HagiCode-org/hagilight/issues"
59
+ },
60
+ "license": "MIT",
61
+ "author": {
62
+ "name": "HagiCode",
63
+ "url": "https://github.com/HagiCode-org"
64
+ }
65
+ }
package/robots.txt.ts ADDED
@@ -0,0 +1,8 @@
1
+ export const prerender = true;
2
+
3
+ export function GET({ site }: { site: URL }) {
4
+ const sitemap = new URL('sitemap-index.xml', new URL(import.meta.env.BASE_URL, site));
5
+ return new Response(`User-agent: *\nAllow: /\nSitemap: ${sitemap.href}\n`, {
6
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
7
+ });
8
+ }
@@ -0,0 +1,10 @@
1
+ import type { APIRoute, GetStaticPaths } from 'astro';
2
+ import rssConfig from 'virtual:hagilight/rss-config';
3
+ import { renderRssFeed } from './dist/rss-runtime.js';
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 './dist/rss-runtime.js';
4
+
5
+ export const GET: APIRoute = () => renderRssFeed(rssConfig, 'en');