@stackql/docusaurus-plugin-aeo 0.4.2 → 0.5.1

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.
@@ -1,47 +1,7 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs').promises;
3
-
4
- function matchAny(value, patterns) {
5
- if (!patterns || patterns.length === 0) return false;
6
- for (const p of patterns) {
7
- if (toRegExp(p).test(value)) return true;
8
- }
9
- return false;
10
- }
11
-
12
- function toRegExp(glob) {
13
- let re = '';
14
- for (let i = 0; i < glob.length; i++) {
15
- const c = glob[i];
16
- if (c === '*') {
17
- if (glob[i + 1] === '*') {
18
- re += '.*';
19
- i++;
20
- if (glob[i + 1] === '/') i++;
21
- } else {
22
- re += '[^/]*';
23
- }
24
- } else if (c === '?') {
25
- re += '[^/]';
26
- } else if ('.+^$()|{}[]\\'.includes(c)) {
27
- re += '\\' + c;
28
- } else {
29
- re += c;
30
- }
31
- }
32
- return new RegExp('^' + re + '$');
33
- }
34
-
35
- function companionUrl(permalink, trailingSlash) {
36
- if (permalink === '/' || permalink === '') {
37
- return '/index.md';
38
- }
39
- const cleaned = permalink.replace(/\/+$/, '');
40
- if (trailingSlash === false) {
41
- return `${cleaned}.md`;
42
- }
43
- return `${cleaned}/index.md`;
44
- }
3
+ const { companionUrl, pageUrl } = require('../companionPath');
4
+ const { matchesRoute } = require('../routeGlob');
45
5
 
46
6
  function groupKind(kind, pluginId) {
47
7
  if (kind === 'docs') return 'docs';
@@ -149,30 +109,20 @@ function buildSections(filtered, options, verbose) {
149
109
  return sections;
150
110
  }
151
111
 
152
- module.exports = async function emitLlmsTxt({
153
- props,
154
- options,
155
- emittedCompanions,
156
- companionsEnabled,
157
- verbose,
158
- }) {
159
- const { outDir, siteConfig } = props;
160
- const trailingSlash = siteConfig.trailingSlash;
161
-
162
- // Filter the companions through include/exclude rules.
163
- const filtered = emittedCompanions.filter((item) => {
164
- if (options.include && !options.include.some((p) => toRegExp(p).test(item.permalink))) {
165
- return false;
166
- }
167
- if (matchAny(item.permalink, options.exclude)) {
168
- return false;
169
- }
170
- return true;
171
- });
172
-
173
- const sections = buildSections(filtered, options, verbose);
112
+ // Normalised view of `llmsTxt.fullTxt`: the boolean form (`true` = every
113
+ // section, no cap) or the object form `{ include, maxBytes }`.
114
+ function fullTxtOptions(fullTxt) {
115
+ if (!fullTxt) return null;
116
+ if (fullTxt === true) return { include: null, maxBytes: null };
117
+ return {
118
+ include: Array.isArray(fullTxt.include) && fullTxt.include.length > 0 ? fullTxt.include : null,
119
+ maxBytes: Number.isFinite(fullTxt.maxBytes) && fullTxt.maxBytes > 0 ? fullTxt.maxBytes : null,
120
+ };
121
+ }
174
122
 
175
- // Build llms.txt.
123
+ // Render llms.txt from the sections. Pure, so it can be tested without a
124
+ // build: returns the file text.
125
+ function renderLlmsTxt({ siteConfig, siteUrl, trailingSlash, sections, options, companionsEnabled }) {
176
126
  const lines = [];
177
127
  lines.push(`# ${siteConfig.title}`);
178
128
  lines.push('');
@@ -189,9 +139,11 @@ module.exports = async function emitLlmsTxt({
189
139
  lines.push(`## ${section.title}`);
190
140
  lines.push('');
191
141
  for (const item of section.items) {
142
+ // Absolute, as the llms.txt spec's examples are: the file is read
143
+ // away from the site, where a relative link has no base.
192
144
  const url = companionsEnabled
193
- ? companionUrl(item.permalink, trailingSlash)
194
- : item.permalink;
145
+ ? companionUrl(siteUrl, item.permalink, trailingSlash, siteConfig.baseUrl)
146
+ : pageUrl(siteUrl, item.permalink, siteConfig.baseUrl);
195
147
  const desc = item.description || siteConfig.tagline || '';
196
148
  const titleText = item.title || item.permalink;
197
149
  if (desc) {
@@ -203,8 +155,72 @@ module.exports = async function emitLlmsTxt({
203
155
  lines.push('');
204
156
  }
205
157
 
158
+ // The llmstxt.org convention for secondary resources: an "Optional"
159
+ // section a reader can skip. llms-full.txt is the whole corpus in one
160
+ // file, so it belongs here rather than inline with the page list.
161
+ if (options.fullTxt && options.linkFullTxt !== false && companionsEnabled) {
162
+ lines.push('## Optional');
163
+ lines.push('');
164
+ // Next to this file: under the locale's base in a localized build.
165
+ const base = String(siteConfig.baseUrl || '/').replace(/\/+$/, '');
166
+ lines.push(
167
+ `- [Full text of every page](${siteUrl}${base}/llms-full.txt): one file with the content of every page listed above`,
168
+ );
169
+ lines.push('');
170
+ }
171
+
172
+ return lines.join('\n');
173
+ }
174
+
175
+ // One block of llms-full.txt: the companion body with a `Source:` line (and
176
+ // the title, when the body does not already open with it). A plain
177
+ // companion already carries both, so the header is skipped and the body
178
+ // is emitted as is.
179
+ function fullTxtBlock({ body, item, siteUrl, baseUrl }) {
180
+ const url = pageUrl(siteUrl, item.permalink, baseUrl);
181
+ const head = body.trimStart().split('\n', 8);
182
+ const hasH1 = /^#\s+\S/.test(head[0] || '');
183
+ const hasSource = head.some((l) => /^Source: \S/.test(l));
184
+ const header = [
185
+ ...(hasH1 ? [] : [`# ${item.title || item.permalink}`, '']),
186
+ ...(hasSource ? [] : [`Source: ${url}`, '']),
187
+ ];
188
+ const prefix = header.length ? `${header.join('\n')}\n` : '';
189
+ return `${prefix}${body.trim()}\n`;
190
+ }
191
+
192
+ module.exports = async function emitLlmsTxt({
193
+ props,
194
+ options,
195
+ emittedCompanions,
196
+ companionsEnabled,
197
+ verbose,
198
+ }) {
199
+ const { outDir, siteConfig } = props;
200
+ const trailingSlash = siteConfig.trailingSlash;
201
+ const siteUrl = (siteConfig.url || '').replace(/\/+$/, '');
202
+
203
+ // Filter the companions through include/exclude rules. A pattern matches
204
+ // a route with or without siteConfig.baseUrl (see src/routeGlob.js), so
205
+ // the same patterns apply in every locale's build.
206
+ const filtered = emittedCompanions.filter((item) => {
207
+ if (options.include && !matchesRoute(item.permalink, options.include, siteConfig.baseUrl)) {
208
+ return false;
209
+ }
210
+ if (matchesRoute(item.permalink, options.exclude, siteConfig.baseUrl)) {
211
+ return false;
212
+ }
213
+ return true;
214
+ });
215
+
216
+ const sections = buildSections(filtered, options, verbose);
217
+
206
218
  const llmsTxtPath = path.join(outDir, 'llms.txt');
207
- await fs.writeFile(llmsTxtPath, lines.join('\n'), 'utf8');
219
+ await fs.writeFile(
220
+ llmsTxtPath,
221
+ renderLlmsTxt({ siteConfig, siteUrl, trailingSlash, sections, options, companionsEnabled }),
222
+ 'utf8',
223
+ );
208
224
  if (verbose) {
209
225
  console.log(`[plugin-aeo] llmsTxt: wrote ${llmsTxtPath}`);
210
226
  }
@@ -212,7 +228,8 @@ module.exports = async function emitLlmsTxt({
212
228
  // Build llms-full.txt by concatenating the companion files that we just
213
229
  // emitted in feature 1. Requires feature 1 to have been enabled. Section
214
230
  // headings mirror llms.txt so an LLM can navigate the corpus by H2.
215
- if (options.fullTxt) {
231
+ const full = fullTxtOptions(options.fullTxt);
232
+ if (full) {
216
233
  if (!companionsEnabled) {
217
234
  if (verbose) {
218
235
  console.log(
@@ -222,9 +239,26 @@ module.exports = async function emitLlmsTxt({
222
239
  return;
223
240
  }
224
241
 
225
- const sectionChunks = [];
226
- for (const section of sections) {
227
- const blocks = [];
242
+ // `include` names content instances ("<plugin>@<id>") or, with per-type
243
+ // grouping, the group keys (docs, blog, pages). Sections keep the order
244
+ // of llms.txt; `maxBytes` stops appending once the next block would
245
+ // cross the cap, so the file always ends on a whole page.
246
+ const wanted = full.include
247
+ ? sections.filter((s) => full.include.includes(s.key))
248
+ : sections;
249
+ if (full.include && wanted.length === 0 && verbose) {
250
+ console.log(
251
+ `[plugin-aeo] llmsTxt: fullTxt.include matched no section (have: ${sections.map((s) => s.key).join(', ')})`,
252
+ );
253
+ }
254
+
255
+ const SEP = '\n---\n\n';
256
+ let out = '';
257
+ let size = 0;
258
+ let truncated = false;
259
+ let pages = 0;
260
+ outer: for (const section of wanted) {
261
+ let sectionOpen = false;
228
262
  for (const item of section.items) {
229
263
  let body;
230
264
  try {
@@ -237,23 +271,31 @@ module.exports = async function emitLlmsTxt({
237
271
  }
238
272
  continue;
239
273
  }
240
- const url = `${(siteConfig.url || '').replace(/\/$/, '')}${item.permalink}`;
241
- const header = [
242
- `# ${item.title || item.permalink}`,
243
- '',
244
- `Source: ${url}`,
245
- '',
246
- ].join('\n');
247
- blocks.push(`${header}${body.trim()}\n`);
274
+ const block = fullTxtBlock({ body, item, siteUrl, baseUrl: siteConfig.baseUrl });
275
+ const chunk = `${sectionOpen ? SEP : `${out ? SEP : ''}## ${section.title}\n\n`}${block}`;
276
+ const bytes = Buffer.byteLength(chunk, 'utf8');
277
+ if (full.maxBytes && size + bytes > full.maxBytes) {
278
+ truncated = true;
279
+ break outer;
280
+ }
281
+ out += chunk;
282
+ size += bytes;
283
+ sectionOpen = true;
284
+ pages++;
248
285
  }
249
- if (blocks.length === 0) continue;
250
- sectionChunks.push(`## ${section.title}\n\n${blocks.join('\n---\n\n')}`);
251
286
  }
252
287
 
253
288
  const fullPath = path.join(outDir, 'llms-full.txt');
254
- await fs.writeFile(fullPath, sectionChunks.join('\n---\n\n'), 'utf8');
289
+ await fs.writeFile(fullPath, out, 'utf8');
255
290
  if (verbose) {
256
- console.log(`[plugin-aeo] llmsTxt: wrote ${fullPath}`);
291
+ console.log(
292
+ `[plugin-aeo] llmsTxt: wrote ${fullPath} (${pages} page(s), ${size} bytes${truncated ? `, stopped at the ${full.maxBytes}-byte cap` : ''})`,
293
+ );
257
294
  }
258
295
  }
259
296
  };
297
+
298
+ module.exports.renderLlmsTxt = renderLlmsTxt;
299
+ module.exports.fullTxtBlock = fullTxtBlock;
300
+ module.exports.fullTxtOptions = fullTxtOptions;
301
+ module.exports.buildSections = buildSections;