@writedocs/generator 0.7.2 → 0.7.4

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.
Files changed (39) hide show
  1. package/astro.config.mjs +10 -4
  2. package/bin/writedocs.js +28 -7
  3. package/package.json +1 -1
  4. package/src/cli/build-auth.js +16 -9
  5. package/src/cli/build.js +3 -1
  6. package/src/cli/convert.js +6 -2
  7. package/src/cli/dev.js +3 -1
  8. package/src/cli/generate-api-pages.js +2 -2
  9. package/src/cli/init.js +3 -1
  10. package/src/cli/output.js +8 -1
  11. package/src/cli/preflight.js +63 -1
  12. package/src/cli/update.js +6 -1
  13. package/src/cli/write-redirects-file.js +2 -1
  14. package/src/components/ApiReferencePanel.astro +46 -12
  15. package/src/components/Steps.astro +2 -2
  16. package/src/layout/BaseLayout.astro +21 -6
  17. package/src/layout/styles/banner.css +1 -1
  18. package/src/lib/a11y-check.js +19 -42
  19. package/src/lib/agent-markdown.js +137 -0
  20. package/src/lib/asset-path.js +29 -0
  21. package/src/lib/color.js +49 -0
  22. package/src/lib/config-file.js +25 -0
  23. package/src/lib/config-schema.js +15 -12
  24. package/src/lib/config-schema.ts +20 -12
  25. package/src/lib/config.ts +18 -135
  26. package/src/lib/json-schema-descriptions.js +1 -1
  27. package/src/lib/llms-index.ts +88 -0
  28. package/src/lib/llms.js +200 -0
  29. package/src/lib/mintlify-convert.js +2 -1
  30. package/src/lib/pages.js +137 -11
  31. package/src/lib/styles-asset-integration.js +1 -18
  32. package/src/lib/writedocs-legacy-convert.js +2 -1
  33. package/src/pages/[...slug].astro +11 -1
  34. package/src/pages/[...slug].md.ts +15 -6
  35. package/src/pages/llms/[...path].md.ts +23 -0
  36. package/src/pages/llms-full.txt.ts +30 -9
  37. package/src/pages/llms.txt.ts +21 -125
  38. package/src/scripts/search.ts +39 -14
  39. package/writedocs.schema.json +2 -2
package/src/lib/pages.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import matter from 'gray-matter';
8
+ import { readConfigText } from './config-file.js';
8
9
 
9
10
  // Top-level folders of a content directory that are never scanned for
10
11
  // pages - build output, dependencies, and static assets. Any folder whose
@@ -60,24 +61,31 @@ function readIgnoreFile(contentDir) {
60
61
  * groups' `page`) - read straight from the file, since this runs before
61
62
  * (and independently of) config validation. Empty when there's no
62
63
  * readable writedocs.json. */
63
- function navigationPageIds(contentDir) {
64
- let config;
65
- try {
66
- config = JSON.parse(fs.readFileSync(path.join(contentDir, 'writedocs.json'), 'utf-8'));
67
- } catch {
68
- return new Set();
69
- }
64
+ /** Every page id a `navigation` lists, in the order a reader meets them -
65
+ * top to bottom, each group's own `page` before its `pages` - without
66
+ * repeats. */
67
+ export function navigationPageOrder(navigation) {
70
68
  const ids = new Set();
71
69
  (function walk(node) {
72
70
  if (Array.isArray(node)) node.forEach((item) => (typeof item === 'string' ? ids.add(item) : walk(item)));
73
71
  else if (node && typeof node === 'object') {
72
+ if (typeof node.page === 'string') ids.add(node.page);
74
73
  for (const [key, value] of Object.entries(node)) {
75
- if (key === 'page' && typeof value === 'string') ids.add(value);
76
- else if (key !== 'openapi' && key !== 'href') walk(value);
74
+ if (key !== 'page' && key !== 'openapi' && key !== 'href') walk(value);
77
75
  }
78
76
  }
79
- })(config.navigation);
80
- return ids;
77
+ })(navigation);
78
+ return [...ids];
79
+ }
80
+
81
+ function navigationPageIds(contentDir) {
82
+ let config;
83
+ try {
84
+ config = JSON.parse(readConfigText(contentDir));
85
+ } catch {
86
+ return new Set();
87
+ }
88
+ return new Set(navigationPageOrder(config.navigation));
81
89
  }
82
90
 
83
91
  /** Recursively finds every .md/.mdx file under `contentDir` that is a page:
@@ -148,3 +156,121 @@ export function findAllPages(contentDir) {
148
156
  export function fileIdForPath(relativePath) {
149
157
  return relativePath.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
150
158
  }
159
+
160
+ /** Any `.css`/`.js` file anywhere in the project - root or any subfolder,
161
+ * `public/` included - auto-loaded site-wide with zero `writedocs.json`
162
+ * config, on top of (not instead of) the explicit `scripts` field
163
+ * (bannerSchema and friends, above). Drop a file in, it loads;
164
+ * there's no field naming which ones to use, matching the same "just
165
+ * works" convention `docs/`'s own file discovery already follows (see
166
+ * findAllPages() above / `content-pipeline.mdx`) - a site author already
167
+ * drops content files in and expects them found, rather than also
168
+ * listing every one in writedocs.json.
169
+ *
170
+ * Two separate walks, because `public/` needs different treatment than
171
+ * everywhere else:
172
+ *
173
+ * - `css`/`js`: every `.css`/`.js` file outside `public/` (root, `docs/`,
174
+ * `snippets/`, any custom folder) - reused as the walk-with-exclusions
175
+ * shape from findAllPages() above, and its exact `EXCLUDED_TOP_LEVEL_DIRS`
176
+ * set (skipped only at the project root, same as there), so `dist/`,
177
+ * `node_modules/`, `.astro/`, `.writedocs/`, `.git/`, and (for this
178
+ * half only - see below) `public/` are never walked into. BaseLayout.astro
179
+ * reads each one's raw content and inlines it as a `<style>`/
180
+ * `<script is:inline>` tag.
181
+ * - `publicCss`/`publicJs`: every `.css`/`.js` file *inside* `public/`,
182
+ * walked separately (starting from `<contentDir>/public` rather than
183
+ * `contentDir` itself, so the same `EXCLUDED_TOP_LEVEL_DIRS` check
184
+ * doesn't apply here - there's no `public/public/` or `public/dist/`
185
+ * convention to guard against). Returned as public-URL-rooted hrefs
186
+ * (a leading `/`, no `public` segment - `public/custom.css` becomes
187
+ * `/custom.css`) rather than content-dir-relative paths, since these
188
+ * files are already served as static assets at exactly that URL once
189
+ * Astro copies `public/` into the build output. BaseLayout.astro
190
+ * renders these as ordinary `<link rel="stylesheet">`/`<script src>`
191
+ * tags pointing at that URL instead of inlining their content -
192
+ * inlining would duplicate every byte (once in the page's own HTML,
193
+ * once more as the independently-fetchable static file at that same
194
+ * URL) for no benefit, where a `<link>`/`<script src>` gets normal
195
+ * browser caching across pages instead of repeating the content on
196
+ * every single page's markup.
197
+ *
198
+ * Both halves are broader than they might sound - a stray `.js` file
199
+ * kept in `docs/`, `snippets/`, or `public/` for an unrelated reason
200
+ * (a snippet's own local helper, an image gallery's lightbox script
201
+ * someone dropped in `public/` to reference from a raw `<script src>`
202
+ * in an .mdx file, say) gets auto-injected sitewide the same as a
203
+ * deliberate one; there's no separate "this one's just tooling" signal
204
+ * to opt out of the convention short of renaming its extension.
205
+ *
206
+ * Both `css`/`js` and `publicCss`/`publicJs` are sorted alphabetically
207
+ * by their respective path for deterministic load order across rebuilds
208
+ * - filesystem readdir order isn't guaranteed portable across OSes or
209
+ * directory-walk order otherwise.
210
+ *
211
+ * `css`/`js` return POSIX-separated paths relative to `contentDir`, not
212
+ * absolute paths or file contents - BaseLayout.astro (the sole caller)
213
+ * resolves and reads each one's content itself, right before inlining
214
+ * it, so a file's content is always current as of that specific
215
+ * request/build rather than cached here across a `writedocs dev`
216
+ * session. `publicCss`/`publicJs` return the public-URL hrefs described
217
+ * above - nothing to read, Astro's own static-file serving/copy already
218
+ * handles those. */
219
+ export function findRootAssets(contentDir) {
220
+ const css = [];
221
+ const js = [];
222
+ function walk(dir, relBase) {
223
+ let entries;
224
+ try {
225
+ entries = fs.readdirSync(dir, { withFileTypes: true });
226
+ } catch {
227
+ return;
228
+ }
229
+ for (const entry of entries) {
230
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
231
+ const abs = path.join(dir, entry.name);
232
+ if (entry.isDirectory()) {
233
+ if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
234
+ // Dot-folders (.github, .claude, .husky, .vscode, ...) hold tooling,
235
+ // never site assets - the same rule findAllPages() applies to pages.
236
+ // Without it, a CI script under .github/ ran on every page.
237
+ if (entry.name.startsWith('.')) continue;
238
+ walk(abs, rel);
239
+ continue;
240
+ }
241
+ if (!entry.isFile() || entry.name.startsWith('.')) continue; // .eslintrc.js and friends
242
+ if (/\.css$/i.test(entry.name)) css.push(rel);
243
+ else if (/\.js$/i.test(entry.name)) js.push(rel);
244
+ }
245
+ }
246
+ walk(contentDir, '');
247
+ css.sort();
248
+ js.sort();
249
+
250
+ const publicCss = [];
251
+ const publicJs = [];
252
+ function walkPublic(dir, relBase) {
253
+ let entries;
254
+ try {
255
+ entries = fs.readdirSync(dir, { withFileTypes: true });
256
+ } catch {
257
+ return;
258
+ }
259
+ for (const entry of entries) {
260
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
261
+ const abs = path.join(dir, entry.name);
262
+ if (entry.isDirectory()) {
263
+ walkPublic(abs, rel);
264
+ continue;
265
+ }
266
+ if (!entry.isFile()) continue;
267
+ if (/\.css$/i.test(entry.name)) publicCss.push(`/${rel}`);
268
+ else if (/\.js$/i.test(entry.name)) publicJs.push(`/${rel}`);
269
+ }
270
+ }
271
+ walkPublic(path.join(contentDir, 'public'), '');
272
+ publicCss.sort();
273
+ publicJs.sort();
274
+
275
+ return { css, js, publicCss, publicJs };
276
+ }
@@ -56,6 +56,7 @@ import fs from 'node:fs';
56
56
  import path from 'node:path';
57
57
  import { fileURLToPath } from 'node:url';
58
58
  import { loadDocsConfig, collectConfiguredAssetPaths } from './config.ts';
59
+ import { resolveOutsidePublic } from './asset-path.js';
59
60
 
60
61
  const MIME_BY_EXTENSION = {
61
62
  '.svg': 'image/svg+xml',
@@ -76,24 +77,6 @@ function mimeFor(filePath) {
76
77
  return MIME_BY_EXTENSION[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
77
78
  }
78
79
 
79
- /** Resolves a root-relative `urlPath` (e.g. "/images/hero.svg") against
80
- * `contentDir` directly - not `<contentDir>/public` - the fallback
81
- * location for one of the styles/footer/seo asset fields
82
- * (collectConfiguredAssetPaths()) when it isn't sitting under public/.
83
- * Segment-by-segment path-traversal guard (no
84
- * `..`/`.` segments) since `urlPath` ultimately comes from an incoming
85
- * request URL in the dev-server case, not just trusted writedocs.json
86
- * content. Returns an absolute path, or null if nothing real is there. */
87
- function resolveOutsidePublic(urlPath, contentDir) {
88
- const segments = urlPath.replace(/^\/+/, '').split('/');
89
- if (segments.some((segment) => segment === '..' || segment === '.' || segment === '')) return null;
90
- const absolute = path.join(contentDir, ...segments);
91
- try {
92
- return fs.statSync(absolute).isFile() ? absolute : null;
93
- } catch {
94
- return null;
95
- }
96
- }
97
80
 
98
81
  function publicPathFor(urlPath, contentDir) {
99
82
  const segments = urlPath.replace(/^\/+/, '').split('/');
@@ -18,12 +18,13 @@ import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import matter from 'gray-matter';
20
20
  import { iconExists } from './icons.js';
21
+ import { readJsonText } from './config-file.js';
21
22
  import { fileIdForPath } from './pages.js';
22
23
  import { Notes, LANGUAGE_NAMES } from './mintlify-convert.js';
23
24
  import { pageUrlResolver } from './link-check.js';
24
25
 
25
26
  export function loadLegacyConfig(file) {
26
- return JSON.parse(fs.readFileSync(file, 'utf-8'));
27
+ return JSON.parse(readJsonText(file));
27
28
  }
28
29
 
29
30
  const PAGE_ENDINGS = ['.api.mdx', '.info.mdx', '.mdx', '.md'];
@@ -271,7 +271,11 @@ interface Props {
271
271
 
272
272
  const rawProps = Astro.props as Props;
273
273
  if (rawProps.redirectTo) {
274
- return Astro.redirect(rawProps.redirectTo);
274
+ // 301, not Astro's default 302: in a static build the status only picks
275
+ // the meta refresh delay (astro/dist/core/routing/3xx.js) - 2 seconds of
276
+ // "Redirecting from..." text for a 302, none for a 301. Hosts that read
277
+ // _redirects get a real 301 for "/" too (write-redirects-file.js).
278
+ return Astro.redirect(rawProps.redirectTo, 301);
275
279
  }
276
280
  const {
277
281
  entry,
@@ -365,6 +369,11 @@ const activePagePosition = flattenNav(activeSection.pages).findIndex((e) => e.sl
365
369
  // plus the always-visible global dropdown list - see BaseLayout.astro
366
370
  // for how each renders.
367
371
  const selectors = buildSelectors(activeSection.path, hrefForSlug, activePagePosition);
372
+ // <html lang>: the `language` of the navigation level this page sits under,
373
+ // if any. A hidden page has no level of its own (activeSection is only a
374
+ // fallback for its chrome), so it keeps the default.
375
+ const languageSegment = isHidden ? undefined : activeSection.path.find((segment) => segment.kind === "language");
376
+ const pageLang = languageSegment ? (languageSegment.items[languageSegment.index] as { language: string }).language : undefined;
368
377
  const globalDropdowns = buildGlobalDropdowns(
369
378
  resolveGlobalDropdowns(config.navigation),
370
379
  currentFileId,
@@ -473,6 +482,7 @@ const components = {
473
482
  selectors={selectors}
474
483
  globalDropdowns={globalDropdowns}
475
484
  mode={pageMode}
485
+ lang={pageLang}
476
486
  >
477
487
  {showSidebar && <Sidebar slot="sidebar" navTree={navTree} hrefForSlug={hrefForSlug} currentSlug={currentFileId} />}
478
488
  {
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import type { APIRoute } from 'astro';
5
5
  import { loadDocsConfig, normalizeEntryId, findAllPages } from '../lib/config';
6
6
  import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
7
- import { applyVisibilityForAgents } from '../lib/visibility.js';
7
+ import { markdownForAgents } from '../lib/agent-markdown.js';
8
8
 
9
9
  // The raw-Markdown twin of [...slug].astro: every content page is also
10
10
  // reachable at the exact same slug with a literal ".md" suffix (e.g.
@@ -58,25 +58,34 @@ export async function getStaticPaths() {
58
58
  .filter((entry) => !entry.data.url)
59
59
  .map((entry) => ({
60
60
  params: { slug: normalizeEntryId(entry.id) },
61
- props: { entry },
61
+ // Passed along rather than re-read per page - GET only renders one entry.
62
+ props: { entry, variables: config.variables },
62
63
  }));
63
64
  }
64
65
 
65
66
  interface Props {
66
67
  entry: DocsEntry;
68
+ variables: Record<string, string>;
67
69
  }
68
70
 
69
71
  export const GET: APIRoute = ({ props }) => {
70
- const { entry } = props as Props;
72
+ const { entry, variables } = props as Props;
73
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
74
+ const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT || process.cwd();
71
75
  // entry.body is the raw, frontmatter-stripped MDX/Markdown source text
72
76
  // Astro's glob() loader already read off disk and stashed on every
73
77
  // entry by default (see astro/dist/content/loaders/glob.js) - reusing
74
78
  // it here means this route needs no separate file read/parse of its
75
79
  // own, and always matches exactly what [...slug].astro rendered from
76
80
  // (same entry, same collection query).
77
- // Mintlify's <Visibility>: this is the agents' view of the page - see
78
- // lib/visibility.js.
79
- const body = applyVisibilityForAgents(entry.body ?? '');
81
+ // What the page shows rather than its raw source: Mintlify's <Visibility>
82
+ // for agents, imported snippets inlined, `variables` filled in - see
83
+ // lib/agent-markdown.js (llms-full.txt uses the same).
84
+ const body = markdownForAgents(entry.body ?? '', {
85
+ file: entry.filePath ? path.resolve(packageRoot, entry.filePath) : undefined,
86
+ contentDir,
87
+ variables,
88
+ });
80
89
  const markdown = `# ${entry.data.title}\n\n${body}`;
81
90
  return new Response(markdown, {
82
91
  headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
@@ -0,0 +1,23 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { llmsIndexFiles } from '../../lib/llms-index';
3
+
4
+ // The child files a big site's llms.txt links to - /llms/<section>.md, one
5
+ // per navigation section that didn't fit in llms.txt itself (see
6
+ // lib/llms.js). Most sites have none: llms.txt holds every page, and this
7
+ // route produces nothing. Nor with a hand-written llms.txt, which replaces
8
+ // the generated index as a whole.
9
+ export async function getStaticPaths() {
10
+ const files = await llmsIndexFiles();
11
+ if (!files) return [];
12
+ return [...files.entries()]
13
+ .filter(([file]) => file.startsWith('llms/'))
14
+ .map(([file, text]) => ({
15
+ params: { path: file.replace(/^llms\//, '').replace(/\.md$/, '') },
16
+ props: { text },
17
+ }));
18
+ }
19
+
20
+ export const GET: APIRoute = ({ props }) =>
21
+ new Response((props as { text: string }).text, {
22
+ headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
23
+ });
@@ -2,9 +2,10 @@ import { getCollection, type CollectionEntry } from 'astro:content';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import type { APIRoute } from 'astro';
5
- import { loadDocsConfig, normalizeEntryId, findAllPages } from '../lib/config';
5
+ import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl, fileIdForEntry } from '../lib/config';
6
6
  import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
7
- import { applyVisibilityForAgents } from '../lib/visibility.js';
7
+ import { markdownForAgents } from '../lib/agent-markdown.js';
8
+ import { orderByNavigation } from '../lib/llms.js';
8
9
 
9
10
  // The "everything, concatenated" half of the llms.txt pair - see
10
11
  // llms.txt.ts (right next to this file) for the lightweight index half,
@@ -29,7 +30,9 @@ export const GET: APIRoute = async () => {
29
30
  });
30
31
  }
31
32
 
33
+ const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT || process.cwd();
32
34
  const config = loadDocsConfig(contentDir);
35
+ const siteUrl = resolveSiteUrl(config);
33
36
 
34
37
  const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
35
38
  const hasPages = findAllPages(contentDir).length > 0;
@@ -39,7 +42,7 @@ export const GET: APIRoute = async () => {
39
42
  ]);
40
43
  const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
41
44
 
42
- const sections = entries
45
+ const unordered = entries
43
46
  // Same exclusion [...slug].md.ts applies to its own per-page raw
44
47
  // Markdown route, for the same reason: an OpenAPI operation page
45
48
  // (generated stub, or a hand-written page that opts into rendering
@@ -54,17 +57,35 @@ export const GET: APIRoute = async () => {
54
57
  .filter((entry) => !entry.data.url)
55
58
  .map((entry: DocsEntry) => {
56
59
  const slug = normalizeEntryId(entry.id);
57
- // Agents' view of <Visibility> blocks, same as the .md route.
58
- const body = applyVisibilityForAgents(entry.body ?? '');
59
- return { slug, title: entry.data.title, body };
60
- })
61
- .sort((a, b) => a.slug.localeCompare(b.slug));
60
+ // What the page shows, not its raw source: <Visibility> for agents,
61
+ // imported snippets inlined, `variables` filled in - same as the .md
62
+ // route (lib/agent-markdown.js).
63
+ const body = markdownForAgents(entry.body ?? '', {
64
+ file: entry.filePath ? path.resolve(packageRoot, entry.filePath) : undefined,
65
+ contentDir,
66
+ variables: config.variables,
67
+ });
68
+ // The page's own URL, so an agent can cite it.
69
+ const url = `${siteUrl ?? ''}${slug === 'index' ? '/' : `/${slug}/`}`;
70
+ return {
71
+ slug,
72
+ fileId: fileIdForEntry(contentDir, packageRoot, entry),
73
+ title: entry.data.title,
74
+ description: entry.data.description,
75
+ url,
76
+ body,
77
+ };
78
+ });
79
+ // Navigation order, same as llms.txt.
80
+ const sections = orderByNavigation(unordered, config.navigation);
62
81
 
63
82
  const lines: string[] = [`# ${config.name}`, ''];
64
83
  if (config.description) lines.push(`> ${config.description}`, '');
65
84
 
66
85
  for (const section of sections) {
67
- lines.push('---', '', `# ${section.title}`, '', section.body.trim(), '');
86
+ lines.push('---', '', `# ${section.title}`, '', `URL: ${section.url}`, '');
87
+ if (section.description) lines.push(`> ${section.description}`, '');
88
+ lines.push(section.body.trim(), '');
68
89
  }
69
90
 
70
91
  const content = lines.join('\n') + '\n';
@@ -1,9 +1,7 @@
1
- import { getCollection, type CollectionEntry } from 'astro:content';
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import type { APIRoute } from 'astro';
5
- import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl } from '../lib/config';
6
- import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
4
+ import { llmsIndexFiles } from '../lib/llms-index';
7
5
 
8
6
  // A lightweight, LLM-facing index of every page on the site - the
9
7
  // llms.txt convention (see https://llmstxt.org), also adopted by Mintlify
@@ -13,132 +11,30 @@ import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
13
11
  // of the pair; llms-full.txt (llms-full.txt.ts, right next to this file)
14
12
  // is the "everything, concatenated" half.
15
13
  //
16
- // Fixed, non-dynamic route (no getStaticPaths/params needed) - Astro
17
- // prerenders this once at the literal path /llms.txt, same as any other
18
- // static-output route (this project's astro.config.mjs sets
19
- // `output: 'static'`), and re-evaluates it per request in `writedocs dev`
20
- // - see [...slug].md.ts for the same fixed-route-vs-catchall distinction
21
- // applied to per-page raw Markdown.
14
+ // Pages are listed under headings that follow the navigation. A site too
15
+ // big for one file keeps this as a directory whose largest sections link to
16
+ // child files - llms/[...path].md.ts serves those - so no page is left out;
17
+ // see lib/llms.js.
22
18
  //
23
- // Unconditional - unlike the per-page .md routes in [...slug].md.ts,
24
- // this doesn't depend on writedocs.json's `contextMenu` field. There's no UI
19
+ // Fixed, non-dynamic route - Astro prerenders this once at the literal path
20
+ // /llms.txt (this project's astro.config.mjs sets `output: 'static'`), and
21
+ // re-evaluates it per request in `writedocs dev`.
22
+ //
23
+ // Unconditional - unlike the per-page .md routes in [...slug].md.ts, this
24
+ // doesn't depend on writedocs.json's `contextMenu` field. There's no UI
25
25
  // footprint to gate (it's an extra static file, not a visible menu), so
26
26
  // it's always generated. `contextMenu` only affects which URL each entry
27
- // links to below (a real fetchable .md URL if the per-page raw-Markdown
28
- // routes exist, the ordinary HTML page URL otherwise).
29
- type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
30
-
31
- // Mirrors Mintlify's own llms.txt behavior: truncate a page's frontmatter
32
- // `description` at the first line break (a multi-paragraph description
33
- // would blow out a one-line list entry) and at 300 characters (an
34
- // arbitrary but reasonable cap - keeps every entry scannable regardless
35
- // of how verbose an individual page's description happens to be).
36
- const DESCRIPTION_MAX_CHARS = 300;
37
- function truncateDescription(description: string | undefined): string | undefined {
38
- if (!description) return undefined;
39
- const firstLine = description.split('\n')[0].trim();
40
- if (!firstLine) return undefined;
41
- return firstLine.length > DESCRIPTION_MAX_CHARS
42
- ? firstLine.slice(0, DESCRIPTION_MAX_CHARS).trimEnd() + '…'
43
- : firstLine;
44
- }
45
-
46
- // Same cap Mintlify documents for its own auto-generated llms.txt - this
47
- // file is meant to stay a lightweight index (one line per page), not
48
- // balloon into something llms-full.txt-sized. Realistically unlikely to
49
- // matter for most Writedocs sites (a site would need on the order of a
50
- // thousand-plus pages with full-length descriptions to hit this), but
51
- // cheap to guard against regardless.
52
- const MAX_CHARS = 100_000;
53
-
27
+ // links to (a real fetchable .md URL if the per-page raw-Markdown routes
28
+ // exist, the ordinary HTML page URL otherwise).
54
29
  export const GET: APIRoute = async () => {
55
- const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
56
-
57
- // A hand-authored llms.txt at the project root (next to writedocs.json)
58
- // always wins outright over the generated one below - same override
59
- // convention Mintlify documents for its own llms.txt. Lets a site
60
- // author hand-curate this file (different ordering, editorial
61
- // descriptions, extra sections) without losing the ability to fall
62
- // back to the auto-generated version by just deleting the override.
63
- const customPath = path.join(contentDir, 'llms.txt');
64
- if (fs.existsSync(customPath)) {
65
- return new Response(fs.readFileSync(customPath, 'utf-8'), {
66
- headers: { 'Content-Type': 'text/plain; charset=utf-8' },
67
- });
68
- }
69
-
70
- const config = loadDocsConfig(contentDir);
71
- const siteUrl = resolveSiteUrl(config); // absolute origin if `domain` is set, else null
72
-
73
- const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
74
- const hasPages = findAllPages(contentDir).length > 0;
75
- const [pagesEntries, generatedDocsEntries] = await Promise.all([
76
- hasPages ? getCollection('pages') : Promise.resolve([]),
77
- hasGeneratedDocs ? getCollection('generatedDocs') : Promise.resolve([]),
78
- ]);
79
- const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
80
-
81
- const items = entries
82
- // A page that opted out of search-engine indexing (frontmatter
83
- // `seo.noindex: true`, already excluded from sitemap.xml - see
84
- // astro.config.mjs's collectNoindexIds()) is excluded here for the
85
- // same reason: llms.txt exists to help external tools discover
86
- // pages, exactly what `noindex` asked not to happen.
87
- .filter((entry) => !entry.data.seo?.noindex)
88
- // A frontmatter `url` page (Mintlify's external link) has no content -
89
- // its own URL only redirects to the link.
90
- .filter((entry) => !entry.data.url)
91
- .map((entry: DocsEntry) => {
92
- const slug = normalizeEntryId(entry.id);
93
- // Same URL Astro's own router resolves this entry to - see
94
- // hrefForSlug() in [...slug].astro for the HTML form, and
95
- // [...slug].md.ts for the .md form (no trailing slash, "index.md"
96
- // for the home page rather than the HTML convention's bare "/").
97
- const path_ = config.contextMenu ? `/${slug}.md` : slug === 'index' ? '/' : `/${slug}/`;
98
- const href = (siteUrl ?? '') + path_;
99
- let description = truncateDescription(entry.data.description);
100
- // Mirrors Mintlify's own behavior: an OpenAPI operation page's
101
- // description gets its "METHOD /path" appended, since the page
102
- // itself renders almost entirely from the spec at request time
103
- // (see ApiPlayground.astro) rather than from frontmatter prose.
104
- if (entry.data.openapi) {
105
- description = description ? `${description} (${entry.data.openapi})` : entry.data.openapi;
106
- }
107
- return { title: entry.data.title, href, description, slug };
108
- })
109
- .sort((a, b) => a.slug.localeCompare(b.slug));
110
-
111
- const lines: string[] = [`# ${config.name}`, ''];
112
- if (config.description) lines.push(`> ${config.description}`, '');
113
- lines.push('## Docs', '');
114
- for (const item of items) {
115
- const entryLine = `- [${item.title}](${item.href})${item.description ? `: ${item.description}` : ''}`;
116
- lines.push(entryLine);
117
- }
118
-
119
- let content = lines.join('\n') + '\n';
120
- if (content.length > MAX_CHARS) {
121
- // Truncate at the last full line inside the budget, then note how
122
- // many entries got cut - rather than silently producing a
123
- // mid-sentence-cutoff file, or (worse) one JSON/Markdown-breaking
124
- // half-written link.
125
- const truncatedLines = lines.slice(0, 4 + (config.description ? 2 : 0)); // "# name" / "" / ["> desc" / ""] / "## Docs" / ""
126
- let runningLength = truncatedLines.join('\n').length + 1;
127
- let omitted = 0;
128
- for (const item of items) {
129
- const entryLine = `- [${item.title}](${item.href})${item.description ? `: ${item.description}` : ''}`;
130
- if (runningLength + entryLine.length + 1 > MAX_CHARS - 200) {
131
- omitted++;
132
- continue;
133
- }
134
- truncatedLines.push(entryLine);
135
- runningLength += entryLine.length + 1;
136
- }
137
- truncatedLines.push('', `_Truncated — ${omitted} more page(s) omitted. See llms-full.txt or the site's own navigation for the complete list._`);
138
- content = truncatedLines.join('\n') + '\n';
139
- }
140
-
141
- return new Response(content, {
30
+ const files = await llmsIndexFiles();
31
+ // null: the project has its own llms.txt at its root, next to
32
+ // writedocs.json - it replaces the generated one (and its child files)
33
+ // outright, and a site can fall back by just deleting it.
34
+ const text = files
35
+ ? files.get('llms.txt')
36
+ : fs.readFileSync(path.join(process.env.WRITEDOCS_CONTENT_DIR || process.cwd(), 'llms.txt'), 'utf-8');
37
+ return new Response(text, {
142
38
  headers: { 'Content-Type': 'text/plain; charset=utf-8' },
143
39
  });
144
40
  };
@@ -1,17 +1,33 @@
1
1
  // SearchModal.astro's own trigger button (rendered inside TopBar.astro) +
2
2
  // overlay/modal. Owns opening/closing the modal, the ⌘K/Ctrl K shortcut,
3
3
  // lazy-loading Pagefind on first open, and rendering results.
4
+
5
+ // The current page's modal, for the ⌘K/Ctrl K shortcut. ClientRouter swaps
6
+ // in a new modal on every navigation and initSearch() runs again for it; the
7
+ // shortcut is one document listener, attached once, that always acts on
8
+ // this. (Attached per page, it piled up one listener per visited page, each
9
+ // still driving that page's detached modal - on a page without a modal of
10
+ // its own, Ctrl K then locked the page's scrolling.)
11
+ let active: { toggle(): void; closeIfOpen(): void } | null = null;
12
+ let shortcutBound = false;
13
+
4
14
  export function initSearch(root: ParentNode) {
15
+ // No trigger button on a `blank` page (no topbar) - the modal and its
16
+ // shortcut still work there, as BaseLayout.astro intends.
5
17
  const trigger = root.querySelector<HTMLButtonElement>('[data-search-trigger]');
6
18
  const overlay = root.querySelector<HTMLElement>('#wd-search-overlay');
7
19
  const input = root.querySelector<HTMLInputElement>('#wd-search-input');
8
20
  const resultsEl = root.querySelector<HTMLElement>('#wd-search-results');
9
- if (!trigger || !overlay || !input || !resultsEl || trigger.dataset.wdInit) return;
10
- trigger.dataset.wdInit = 'true';
21
+ if (!overlay || !input || !resultsEl) {
22
+ active = null;
23
+ return;
24
+ }
25
+ if (overlay.dataset.wdInit) return;
26
+ overlay.dataset.wdInit = 'true';
11
27
 
12
28
  // Most keyboards outside macOS/iOS don't have a command key - "Ctrl K"
13
29
  // reads more naturally there than the ⌘ glyph.
14
- const kbd = trigger.querySelector('.wd-search-kbd');
30
+ const kbd = trigger?.querySelector('.wd-search-kbd');
15
31
  if (kbd && !/Mac|iPhone|iPad/.test(navigator.userAgent)) {
16
32
  kbd.textContent = 'Ctrl K';
17
33
  }
@@ -122,10 +138,10 @@ export function initSearch(root: ParentNode) {
122
138
  function close() {
123
139
  overlay.hidden = true;
124
140
  document.body.style.overflow = '';
125
- trigger.focus();
141
+ trigger?.focus();
126
142
  }
127
143
 
128
- trigger.addEventListener('click', open);
144
+ trigger?.addEventListener('click', open);
129
145
  overlay.addEventListener('click', (e) => {
130
146
  if (e.target === overlay) close();
131
147
  });
@@ -143,13 +159,22 @@ export function initSearch(root: ParentNode) {
143
159
  close();
144
160
  }
145
161
  });
146
- document.addEventListener('keydown', (e) => {
147
- if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
148
- e.preventDefault();
149
- if (overlay.hidden) open();
150
- else close();
151
- } else if (e.key === 'Escape' && !overlay.hidden) {
152
- close();
153
- }
154
- });
162
+ active = {
163
+ toggle: () => (overlay.hidden ? open() : close()),
164
+ closeIfOpen: () => {
165
+ if (!overlay.hidden) close();
166
+ },
167
+ };
168
+ if (!shortcutBound) {
169
+ shortcutBound = true;
170
+ document.addEventListener('keydown', (e) => {
171
+ if (!active) return;
172
+ if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
173
+ e.preventDefault();
174
+ active.toggle();
175
+ } else if (e.key === 'Escape') {
176
+ active.closeIfOpen();
177
+ }
178
+ });
179
+ }
155
180
  }