@hagicode/hagilight 0.9.5-dev.109.1.aafdacc → 0.9.7-dev.111.1.899dd08
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/README.md +24 -0
- package/dist/integration.d.ts +21 -1
- package/dist/integration.js +74 -3
- package/dist/llms.d.ts +43 -0
- package/dist/llms.js +183 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -49,6 +49,30 @@ The values come from `HAGILIGHT_BUILD_BRANCH` and `HAGILIGHT_BUILD_COMMIT`, then
|
|
|
49
49
|
|
|
50
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
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
|
+
|
|
52
76
|
## Body images
|
|
53
77
|
|
|
54
78
|
`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.
|
package/dist/integration.d.ts
CHANGED
|
@@ -21,6 +21,26 @@ export interface HagilightOptions {
|
|
|
21
21
|
* Independent of `enabled`, which controls only sitemap and robots.
|
|
22
22
|
*/
|
|
23
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
|
+
export interface HagilightLlmsOptions {
|
|
32
|
+
/** Defaults to `true`. */
|
|
33
|
+
enabled?: boolean;
|
|
34
|
+
/** Site name for the H1. Defaults to the home page `og:site_name` or title. */
|
|
35
|
+
title?: string;
|
|
36
|
+
/** Summary blockquote. Defaults to the home page description. */
|
|
37
|
+
summary?: string;
|
|
38
|
+
/** Language to list, for example `en-US`. Defaults to the Astro default locale, then the home page language. */
|
|
39
|
+
lang?: string;
|
|
40
|
+
/** Maximum links per section. Defaults to 12. */
|
|
41
|
+
maxLinksPerSection?: number;
|
|
42
|
+
/** Maximum links in total. Defaults to 120. */
|
|
43
|
+
maxLinks?: number;
|
|
24
44
|
}
|
|
25
45
|
export interface HagilightContentImagesOptions {
|
|
26
46
|
/** Defaults to `true`. */
|
|
@@ -39,6 +59,6 @@ export type HagilightFaviconOptions = FaviconOptions;
|
|
|
39
59
|
* owns sitemap generation, a consumer-owned robots page or public file takes
|
|
40
60
|
* precedence, and an enabled Starlight Hagilight integration owns RSS feeds.
|
|
41
61
|
*/
|
|
42
|
-
export declare function hagilight({ enabled, rss, contentImages }?: HagilightOptions): AstroIntegration;
|
|
62
|
+
export declare function hagilight({ enabled, rss, contentImages, llms }?: HagilightOptions): AstroIntegration;
|
|
43
63
|
/** Inject the bundled HagiCode favicon unless the site head already declares an icon. */
|
|
44
64
|
export declare function hagilightFavicon(options?: HagilightFaviconOptions): AstroIntegration;
|
package/dist/integration.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { extname, isAbsolute, resolve, win32 } from 'node:path';
|
|
3
|
+
import { extname, isAbsolute, join, resolve, win32 } from 'node:path';
|
|
4
4
|
import sitemap from '@astrojs/sitemap';
|
|
5
5
|
import { resolveBuildInfo } from '@hagicode/hagilight-core/build-info-resolver';
|
|
6
6
|
import { checkContentImageRegistration, registerContentImages } from '@hagicode/hagilight-core/content-images';
|
|
7
7
|
import { resolveFaviconHeadEntry } from '@hagicode/hagilight-core/favicon';
|
|
8
8
|
import { resolveRssLocales } from '@hagicode/hagilight-core/rss';
|
|
9
9
|
import { resolvePlainAstroRssOwner } from '@hagicode/hagilight-core/rss-ownership';
|
|
10
|
+
import { buildLlmsTxt, parsePageMeta } from './llms.js';
|
|
10
11
|
const VIRTUAL_RSS_CONFIG = 'virtual:hagilight/rss-config';
|
|
11
12
|
const VIRTUAL_RSS_CONFIG_RESOLVED = `\0${VIRTUAL_RSS_CONFIG}`;
|
|
12
13
|
const VIRTUAL_BUILD_INFO = 'virtual:hagilight/build-info';
|
|
@@ -51,7 +52,7 @@ const BUILTIN_RSS_FEED_SOURCE = BUILTIN_RSS_FEED.toString();
|
|
|
51
52
|
* owns sitemap generation, a consumer-owned robots page or public file takes
|
|
52
53
|
* precedence, and an enabled Starlight Hagilight integration owns RSS feeds.
|
|
53
54
|
*/
|
|
54
|
-
export function hagilight({ enabled = true, rss, contentImages } = {}) {
|
|
55
|
+
export function hagilight({ enabled = true, rss, contentImages, llms } = {}) {
|
|
55
56
|
if (typeof enabled !== 'boolean') {
|
|
56
57
|
throw new TypeError('Hagilight sitemap and robots enabled option must be a boolean.');
|
|
57
58
|
}
|
|
@@ -61,6 +62,7 @@ export function hagilight({ enabled = true, rss, contentImages } = {}) {
|
|
|
61
62
|
if (isRecord(contentImages) && contentImages.enabled !== undefined && typeof contentImages.enabled !== 'boolean') {
|
|
62
63
|
throw new TypeError('Hagilight content images enabled option must be a boolean.');
|
|
63
64
|
}
|
|
65
|
+
const llmsOptions = resolveLlmsOptions(llms);
|
|
64
66
|
const contentImagesEnabled = typeof contentImages === 'boolean' ? contentImages : contentImages?.enabled !== false;
|
|
65
67
|
if (rss !== undefined && typeof rss !== 'boolean' && !isRecord(rss)) {
|
|
66
68
|
throw new TypeError('Hagilight RSS option must be a boolean or an options object.');
|
|
@@ -73,6 +75,7 @@ export function hagilight({ enabled = true, rss, contentImages } = {}) {
|
|
|
73
75
|
throw new TypeError('Hagilight RSS getFeed must be a non-empty module path.');
|
|
74
76
|
}
|
|
75
77
|
}
|
|
78
|
+
let resolvedConfig;
|
|
76
79
|
return {
|
|
77
80
|
name: '@hagicode/hagilight:discovery',
|
|
78
81
|
hooks: {
|
|
@@ -130,11 +133,79 @@ export function hagilight({ enabled = true, rss, contentImages } = {}) {
|
|
|
130
133
|
});
|
|
131
134
|
},
|
|
132
135
|
'astro:config:done'({ config, logger }) {
|
|
136
|
+
resolvedConfig = config;
|
|
133
137
|
checkContentImageRegistration({ config, logger, enabled: contentImagesEnabled });
|
|
134
138
|
},
|
|
139
|
+
'astro:build:done'({ dir, pages, logger }) {
|
|
140
|
+
if (!enabled || !llmsOptions.enabled || !resolvedConfig)
|
|
141
|
+
return;
|
|
142
|
+
writeLlmsTxt({ config: resolvedConfig, dir, pages, options: llmsOptions, logger });
|
|
143
|
+
},
|
|
135
144
|
},
|
|
136
145
|
};
|
|
137
146
|
}
|
|
147
|
+
function resolveLlmsOptions(llms) {
|
|
148
|
+
if (llms !== undefined && typeof llms !== 'boolean' && !isRecord(llms)) {
|
|
149
|
+
throw new TypeError('Hagilight llms option must be a boolean or an options object.');
|
|
150
|
+
}
|
|
151
|
+
if (typeof llms === 'boolean')
|
|
152
|
+
return { enabled: llms };
|
|
153
|
+
const options = llms ?? {};
|
|
154
|
+
if (options.enabled !== undefined && typeof options.enabled !== 'boolean') {
|
|
155
|
+
throw new TypeError('Hagilight llms enabled option must be a boolean.');
|
|
156
|
+
}
|
|
157
|
+
for (const key of ['title', 'summary', 'lang']) {
|
|
158
|
+
if (options[key] !== undefined && (typeof options[key] !== 'string' || !options[key].trim())) {
|
|
159
|
+
throw new TypeError(`Hagilight llms ${key} must be a non-empty string.`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const key of ['maxLinksPerSection', 'maxLinks']) {
|
|
163
|
+
if (options[key] !== undefined && (!Number.isInteger(options[key]) || options[key] < 1)) {
|
|
164
|
+
throw new TypeError(`Hagilight llms ${key} must be a positive integer.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { ...options, enabled: options.enabled !== false };
|
|
168
|
+
}
|
|
169
|
+
function writeLlmsTxt({ config, dir, pages, options, logger }) {
|
|
170
|
+
const outDir = fileURLToPath(dir);
|
|
171
|
+
const target = join(outDir, 'llms.txt');
|
|
172
|
+
if (existsSync(target)) {
|
|
173
|
+
logger.info('llms.txt already exists in the build output; leaving it untouched.');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const base = `${config.base || '/'}`.replace(/\/?$/u, '/');
|
|
177
|
+
const siteBase = new URL(base, config.site);
|
|
178
|
+
const i18n = config.i18n;
|
|
179
|
+
const entries = [];
|
|
180
|
+
for (const { pathname } of pages) {
|
|
181
|
+
const relative = pathname.replace(/^\/+/u, '');
|
|
182
|
+
const candidates = relative === '' || relative.endsWith('/')
|
|
183
|
+
? [join(outDir, relative, 'index.html')]
|
|
184
|
+
: [join(outDir, relative, 'index.html'), join(outDir, `${relative}.html`)];
|
|
185
|
+
const file = candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
|
|
186
|
+
if (!file)
|
|
187
|
+
continue;
|
|
188
|
+
const url = new URL(relative, siteBase).href;
|
|
189
|
+
const meta = parsePageMeta(readFileSync(file, 'utf8'), url);
|
|
190
|
+
if (!meta)
|
|
191
|
+
continue;
|
|
192
|
+
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 });
|
|
193
|
+
}
|
|
194
|
+
const body = buildLlmsTxt({
|
|
195
|
+
pages: entries,
|
|
196
|
+
title: options.title,
|
|
197
|
+
summary: options.summary,
|
|
198
|
+
lang: options.lang ?? i18n?.defaultLocale,
|
|
199
|
+
maxLinksPerSection: options.maxLinksPerSection,
|
|
200
|
+
maxLinks: options.maxLinks,
|
|
201
|
+
});
|
|
202
|
+
if (!body) {
|
|
203
|
+
logger.warn('No indexable pages were found, so llms.txt was not generated.');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
writeFileSync(target, body, 'utf8');
|
|
207
|
+
logger.info(`Generated llms.txt from ${entries.length} page(s).`);
|
|
208
|
+
}
|
|
138
209
|
function deriveRssLocales(locales, config) {
|
|
139
210
|
if (locales !== undefined)
|
|
140
211
|
return locales;
|
package/dist/llms.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
export declare function parsePageMeta(html: string, pageUrl: string): LlmsPageMeta | undefined;
|
|
42
|
+
/** Build the `llms.txt` body. Returns `undefined` when there is nothing to list. */
|
|
43
|
+
export declare function buildLlmsTxt(options: BuildLlmsTxtOptions): string | undefined;
|
package/dist/llms.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
export function parsePageMeta(html, pageUrl) {
|
|
50
|
+
const headEnd = html.indexOf('</head>');
|
|
51
|
+
const head = headEnd === -1 ? html : html.slice(0, headEnd);
|
|
52
|
+
const metas = [...head.matchAll(/<meta\b[^>]*>/giu)].map(([tag]) => attributes(tag));
|
|
53
|
+
const named = (name) => metas.find((meta) => meta.name?.toLowerCase() === name)?.content;
|
|
54
|
+
const property = (name) => metas.find((meta) => meta.property?.toLowerCase() === name)?.content;
|
|
55
|
+
if (/noindex/iu.test(named('robots') ?? ''))
|
|
56
|
+
return undefined;
|
|
57
|
+
if (metas.some((meta) => meta['http-equiv']?.toLowerCase() === 'refresh'))
|
|
58
|
+
return undefined;
|
|
59
|
+
const canonicalTag = [...head.matchAll(/<link\b[^>]*>/giu)]
|
|
60
|
+
.map(([tag]) => attributes(tag))
|
|
61
|
+
.find((link) => link.rel?.toLowerCase().split(/\s+/u).includes('canonical'));
|
|
62
|
+
if (canonicalTag?.href) {
|
|
63
|
+
const canonical = normalizeUrl(canonicalTag.href, pageUrl);
|
|
64
|
+
const own = normalizeUrl(pageUrl, pageUrl);
|
|
65
|
+
// A trailing-slash difference is a site setting, not a different page.
|
|
66
|
+
if (canonical && own && canonical.replace(/\/$/u, '') !== own.replace(/\/$/u, ''))
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const title = clean(decodeEntities(head.match(/<title[^>]*>([\s\S]*?)<\/title>/iu)?.[1] ?? ''));
|
|
70
|
+
if (!title || /^(?:404\b|page not found)/iu.test(title))
|
|
71
|
+
return undefined;
|
|
72
|
+
const htmlTag = html.match(/<html\b[^>]*>/iu)?.[0];
|
|
73
|
+
const lang = clean(htmlTag ? attributes(htmlTag).lang : undefined);
|
|
74
|
+
return {
|
|
75
|
+
lang,
|
|
76
|
+
title,
|
|
77
|
+
description: clean(named('description') ?? property('og:description')),
|
|
78
|
+
siteName: clean(property('og:site_name')),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function sameLanguage(a, b) {
|
|
82
|
+
if (!a || !b)
|
|
83
|
+
return false;
|
|
84
|
+
const x = a.toLowerCase();
|
|
85
|
+
const y = b.toLowerCase();
|
|
86
|
+
return x === y || x.startsWith(`${y}-`) || y.startsWith(`${x}-`);
|
|
87
|
+
}
|
|
88
|
+
function segmentsOf(page) {
|
|
89
|
+
const segments = page.path.split('/').filter(Boolean);
|
|
90
|
+
if (segments.length > 0 && LOCALE_SEGMENT.test(segments[0]) && (sameLanguage(segments[0], page.lang) || segments.length === 1)) {
|
|
91
|
+
segments.shift();
|
|
92
|
+
}
|
|
93
|
+
return segments;
|
|
94
|
+
}
|
|
95
|
+
function humanize(segment) {
|
|
96
|
+
const text = decodeURIComponent(segment).replace(/[-_]+/gu, ' ').trim();
|
|
97
|
+
return text.replace(/\b[a-z]/gu, (letter) => letter.toUpperCase());
|
|
98
|
+
}
|
|
99
|
+
function stripSiteSuffix(title, siteName) {
|
|
100
|
+
if (!siteName)
|
|
101
|
+
return title;
|
|
102
|
+
const escaped = siteName.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
103
|
+
const stripped = title.replace(new RegExp(`\\s*[|•·–—-]\\s*${escaped}$`, 'iu'), '').trim();
|
|
104
|
+
return stripped || title;
|
|
105
|
+
}
|
|
106
|
+
function mostCommonLanguage(pages) {
|
|
107
|
+
const counts = new Map();
|
|
108
|
+
for (const page of pages)
|
|
109
|
+
if (page.lang)
|
|
110
|
+
counts.set(page.lang, (counts.get(page.lang) ?? 0) + 1);
|
|
111
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
|
|
112
|
+
}
|
|
113
|
+
function mostCommonSiteName(pages) {
|
|
114
|
+
const counts = new Map();
|
|
115
|
+
for (const page of pages)
|
|
116
|
+
if (page.siteName)
|
|
117
|
+
counts.set(page.siteName, (counts.get(page.siteName) ?? 0) + 1);
|
|
118
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0];
|
|
119
|
+
}
|
|
120
|
+
function linkLine(page, siteName, generic) {
|
|
121
|
+
const title = stripSiteSuffix(page.title, siteName).replace(/[[\]]/gu, '');
|
|
122
|
+
return page.description && !generic.has(page.description) ? `- [${title}](${page.url}): ${page.description}` : `- [${title}](${page.url})`;
|
|
123
|
+
}
|
|
124
|
+
/** Build the `llms.txt` body. Returns `undefined` when there is nothing to list. */
|
|
125
|
+
export function buildLlmsTxt(options) {
|
|
126
|
+
const maxPerSection = options.maxLinksPerSection ?? DEFAULT_MAX_LINKS_PER_SECTION;
|
|
127
|
+
const maxLinks = options.maxLinks ?? DEFAULT_MAX_LINKS;
|
|
128
|
+
const all = [...options.pages].sort((a, b) => a.path.localeCompare(b.path));
|
|
129
|
+
if (all.length === 0)
|
|
130
|
+
return undefined;
|
|
131
|
+
const shallowest = (candidates) => [...candidates].sort((a, b) => segmentsOf(a).length - segmentsOf(b).length || a.path.length - b.path.length)[0];
|
|
132
|
+
let lang = options.lang && all.some((page) => sameLanguage(page.lang, options.lang))
|
|
133
|
+
? options.lang
|
|
134
|
+
: shallowest(all.filter((page) => segmentsOf(page).length === 0))?.lang ?? mostCommonLanguage(all);
|
|
135
|
+
if (!all.some((page) => sameLanguage(page.lang, lang)))
|
|
136
|
+
lang = mostCommonLanguage(all);
|
|
137
|
+
const inLanguage = all.filter((page) => lang === undefined || sameLanguage(page.lang, lang));
|
|
138
|
+
const home = shallowest(inLanguage.filter((page) => segmentsOf(page).length === 0)) ?? shallowest(inLanguage);
|
|
139
|
+
const siteName = clean(options.title) ?? home?.siteName ?? mostCommonSiteName(inLanguage) ?? home?.title;
|
|
140
|
+
const summary = clean(options.summary) ?? home?.description;
|
|
141
|
+
const sections = new Map();
|
|
142
|
+
const hasChildren = (segment) => inLanguage.some((other) => {
|
|
143
|
+
const parts = segmentsOf(other);
|
|
144
|
+
return parts[0] === segment && parts.length > 1;
|
|
145
|
+
});
|
|
146
|
+
for (const page of inLanguage) {
|
|
147
|
+
const segments = segmentsOf(page);
|
|
148
|
+
// Top-level pages without children (for example /about/) go to Overview; /guides/... goes to Guides.
|
|
149
|
+
const key = segments.length === 0 || (segments.length === 1 && !hasChildren(segments[0]))
|
|
150
|
+
? 'Overview'
|
|
151
|
+
: humanize(segments[0]);
|
|
152
|
+
sections.set(key, [...(sections.get(key) ?? []), page]);
|
|
153
|
+
}
|
|
154
|
+
// A description repeated across pages is a site-wide default, not information about the page.
|
|
155
|
+
const descriptionCounts = new Map();
|
|
156
|
+
for (const page of inLanguage)
|
|
157
|
+
if (page.description)
|
|
158
|
+
descriptionCounts.set(page.description, (descriptionCounts.get(page.description) ?? 0) + 1);
|
|
159
|
+
const generic = new Set([...descriptionCounts].filter(([, count]) => count >= GENERIC_DESCRIPTION_MIN_PAGES).map(([text]) => text));
|
|
160
|
+
const lines = [`# ${siteName ?? 'Site'}`];
|
|
161
|
+
if (summary)
|
|
162
|
+
lines.push('', `> ${summary}`);
|
|
163
|
+
let remaining = maxLinks;
|
|
164
|
+
const ordered = [...sections.entries()].sort(([a, pa], [b, pb]) => (a === 'Overview' ? -1 : 0) - (b === 'Overview' ? -1 : 0) || pb.length - pa.length || a.localeCompare(b));
|
|
165
|
+
for (const [name, pages] of ordered) {
|
|
166
|
+
if (remaining <= 0)
|
|
167
|
+
break;
|
|
168
|
+
// Flat sites keep most pages under Overview, so it gets a larger share than a topical section.
|
|
169
|
+
const limit = name === 'Overview' ? maxPerSection * OVERVIEW_LIMIT_FACTOR : maxPerSection;
|
|
170
|
+
const picked = [...pages]
|
|
171
|
+
.sort((a, b) => segmentsOf(a).length - segmentsOf(b).length || a.path.localeCompare(b.path))
|
|
172
|
+
.slice(0, Math.min(limit, remaining));
|
|
173
|
+
remaining -= picked.length;
|
|
174
|
+
lines.push('', `## ${name}`, '', ...picked.map((page) => linkLine(page, siteName, generic)));
|
|
175
|
+
}
|
|
176
|
+
const alternates = all
|
|
177
|
+
.filter((page) => lang !== undefined && page.lang && !sameLanguage(page.lang, lang) && segmentsOf(page).length === 0)
|
|
178
|
+
.sort((a, b) => (a.lang ?? '').localeCompare(b.lang ?? ''));
|
|
179
|
+
if (alternates.length > 0) {
|
|
180
|
+
lines.push('', '## Optional', '', ...alternates.map((page) => `- [${page.lang}](${page.url})`));
|
|
181
|
+
}
|
|
182
|
+
return `${lines.join('\n')}\n`;
|
|
183
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hagicode/hagilight",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7-dev.111.1.899dd08",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Astro integrations and components for HagiCode sites",
|
|
6
6
|
"repository": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@astrojs/sitemap": "^3.7.4",
|
|
43
|
-
"@hagicode/hagilight-core": "0.9.
|
|
43
|
+
"@hagicode/hagilight-core": "0.9.7-dev.111.1.899dd08"
|
|
44
44
|
},
|
|
45
45
|
"keywords": [
|
|
46
46
|
"astro-component",
|