@writedocs/generator 0.7.2 → 0.7.3

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.
@@ -155,7 +155,7 @@ export const DESCRIPTIONS = {
155
155
  ...logo('footer.logo'),
156
156
 
157
157
  api: 'The API playground.',
158
- 'api.proxy': 'Send "Try it" requests through writedocs\' proxy, for APIs that don\'t allow calls from other sites (CORS). Default true.',
158
+ 'api.proxy': 'When the browser blocks a "Try it" request (the API doesn\'t allow calls from other sites - CORS), retry it through writedocs\' proxy. Requests always go to the API directly first. Default true.',
159
159
  domain: 'The site\'s address, like "docs.example.com". Turns on sitemap.xml and absolute URLs in link previews.',
160
160
 
161
161
  seo: 'Default metadata for every page. A page\'s own `seo` frontmatter overrides it field by field.',
@@ -11,6 +11,7 @@
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { iconExists } from './icons.js';
14
+ import { readJsonText } from './config-file.js';
14
15
 
15
16
  // ---------------------------------------------------------------------
16
17
  // Reading docs.json
@@ -28,7 +29,7 @@ export function loadMintlifyConfig(file) {
28
29
  if (seen.has(abs)) throw new Error(`Circular $ref: ${path.relative(root, abs)}`);
29
30
  seen.add(abs);
30
31
  try {
31
- return resolve(JSON.parse(fs.readFileSync(abs, 'utf-8')), path.dirname(abs));
32
+ return resolve(JSON.parse(readJsonText(abs)), path.dirname(abs));
32
33
  } finally {
33
34
  seen.delete(abs);
34
35
  }
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
@@ -63,7 +64,7 @@ function readIgnoreFile(contentDir) {
63
64
  function navigationPageIds(contentDir) {
64
65
  let config;
65
66
  try {
66
- config = JSON.parse(fs.readFileSync(path.join(contentDir, 'writedocs.json'), 'utf-8'));
67
+ config = JSON.parse(readConfigText(contentDir));
67
68
  } catch {
68
69
  return new Set();
69
70
  }
@@ -148,3 +149,122 @@ export function findAllPages(contentDir) {
148
149
  export function fileIdForPath(relativePath) {
149
150
  return relativePath.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
150
151
  }
152
+
153
+ /** Any `.css`/`.js` file anywhere in the project - root or any subfolder,
154
+ * `public/` included - auto-loaded site-wide with zero `writedocs.json`
155
+ * config, on top of (not instead of) the explicit `scripts` field
156
+ * (bannerSchema and friends, above). Drop a file in, it loads;
157
+ * there's no field naming which ones to use, matching the same "just
158
+ * works" convention `docs/`'s own file discovery already follows (see
159
+ * findAllPages() above / `content-pipeline.mdx`) - a site author already
160
+ * drops content files in and expects them found, rather than also
161
+ * listing every one in writedocs.json.
162
+ *
163
+ * Two separate walks, because `public/` needs different treatment than
164
+ * everywhere else:
165
+ *
166
+ * - `css`/`js`: every `.css`/`.js` file outside `public/` (root, `docs/`,
167
+ * `snippets/`, any custom folder) - reused as the walk-with-exclusions
168
+ * shape from findAllPages() above, and its exact `EXCLUDED_TOP_LEVEL_DIRS`
169
+ * set (skipped only at the project root, same as there), so `dist/`,
170
+ * `node_modules/`, `.astro/`, `.writedocs/`, `.git/`, and (for this
171
+ * half only - see below) `public/` are never walked into. BaseLayout.astro
172
+ * reads each one's raw content and inlines it as a `<style>`/
173
+ * `<script is:inline>` tag.
174
+ * - `publicCss`/`publicJs`: every `.css`/`.js` file *inside* `public/`,
175
+ * walked separately (starting from `<contentDir>/public` rather than
176
+ * `contentDir` itself, so the same `EXCLUDED_TOP_LEVEL_DIRS` check
177
+ * doesn't apply here - there's no `public/public/` or `public/dist/`
178
+ * convention to guard against). Returned as public-URL-rooted hrefs
179
+ * (a leading `/`, no `public` segment - `public/custom.css` becomes
180
+ * `/custom.css`) rather than content-dir-relative paths, since these
181
+ * files are already served as static assets at exactly that URL once
182
+ * Astro copies `public/` into the build output. BaseLayout.astro
183
+ * renders these as ordinary `<link rel="stylesheet">`/`<script src>`
184
+ * tags pointing at that URL instead of inlining their content -
185
+ * inlining would duplicate every byte (once in the page's own HTML,
186
+ * once more as the independently-fetchable static file at that same
187
+ * URL) for no benefit, where a `<link>`/`<script src>` gets normal
188
+ * browser caching across pages instead of repeating the content on
189
+ * every single page's markup.
190
+ *
191
+ * Both halves are broader than they might sound - a stray `.js` file
192
+ * kept in `docs/`, `snippets/`, or `public/` for an unrelated reason
193
+ * (a snippet's own local helper, an image gallery's lightbox script
194
+ * someone dropped in `public/` to reference from a raw `<script src>`
195
+ * in an .mdx file, say) gets auto-injected sitewide the same as a
196
+ * deliberate one; there's no separate "this one's just tooling" signal
197
+ * to opt out of the convention short of renaming its extension.
198
+ *
199
+ * Both `css`/`js` and `publicCss`/`publicJs` are sorted alphabetically
200
+ * by their respective path for deterministic load order across rebuilds
201
+ * - same reasoning as llms.txt's own alphabetical-by-slug sort (see
202
+ * llms.txt.ts) - filesystem readdir order isn't guaranteed portable
203
+ * across OSes or directory-walk order otherwise.
204
+ *
205
+ * `css`/`js` return POSIX-separated paths relative to `contentDir`, not
206
+ * absolute paths or file contents - BaseLayout.astro (the sole caller)
207
+ * resolves and reads each one's content itself, right before inlining
208
+ * it, so a file's content is always current as of that specific
209
+ * request/build rather than cached here across a `writedocs dev`
210
+ * session. `publicCss`/`publicJs` return the public-URL hrefs described
211
+ * above - nothing to read, Astro's own static-file serving/copy already
212
+ * handles those. */
213
+ export function findRootAssets(contentDir) {
214
+ const css = [];
215
+ const js = [];
216
+ function walk(dir, relBase) {
217
+ let entries;
218
+ try {
219
+ entries = fs.readdirSync(dir, { withFileTypes: true });
220
+ } catch {
221
+ return;
222
+ }
223
+ for (const entry of entries) {
224
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
225
+ const abs = path.join(dir, entry.name);
226
+ if (entry.isDirectory()) {
227
+ if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
228
+ // Dot-folders (.github, .claude, .husky, .vscode, ...) hold tooling,
229
+ // never site assets - the same rule findAllPages() applies to pages.
230
+ // Without it, a CI script under .github/ ran on every page.
231
+ if (entry.name.startsWith('.')) continue;
232
+ walk(abs, rel);
233
+ continue;
234
+ }
235
+ if (!entry.isFile() || entry.name.startsWith('.')) continue; // .eslintrc.js and friends
236
+ if (/\.css$/i.test(entry.name)) css.push(rel);
237
+ else if (/\.js$/i.test(entry.name)) js.push(rel);
238
+ }
239
+ }
240
+ walk(contentDir, '');
241
+ css.sort();
242
+ js.sort();
243
+
244
+ const publicCss = [];
245
+ const publicJs = [];
246
+ function walkPublic(dir, relBase) {
247
+ let entries;
248
+ try {
249
+ entries = fs.readdirSync(dir, { withFileTypes: true });
250
+ } catch {
251
+ return;
252
+ }
253
+ for (const entry of entries) {
254
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
255
+ const abs = path.join(dir, entry.name);
256
+ if (entry.isDirectory()) {
257
+ walkPublic(abs, rel);
258
+ continue;
259
+ }
260
+ if (!entry.isFile()) continue;
261
+ if (/\.css$/i.test(entry.name)) publicCss.push(`/${rel}`);
262
+ else if (/\.js$/i.test(entry.name)) publicJs.push(`/${rel}`);
263
+ }
264
+ }
265
+ walkPublic(path.join(contentDir, 'public'), '');
266
+ publicCss.sort();
267
+ publicJs.sort();
268
+
269
+ return { css, js, publicCss, publicJs };
270
+ }
@@ -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
  {
@@ -94,7 +94,11 @@ export const GET: APIRoute = async () => {
94
94
  // hrefForSlug() in [...slug].astro for the HTML form, and
95
95
  // [...slug].md.ts for the .md form (no trailing slash, "index.md"
96
96
  // for the home page rather than the HTML convention's bare "/").
97
- const path_ = config.contextMenu ? `/${slug}.md` : slug === 'index' ? '/' : `/${slug}/`;
97
+ // An OpenAPI operation page has no .md form ([...slug].md.ts leaves
98
+ // it out - it renders from the spec, not prose), so it keeps its HTML
99
+ // URL even when the .md routes exist.
100
+ const hasMarkdownRoute = config.contextMenu && !entry.data.openapi;
101
+ const path_ = hasMarkdownRoute ? `/${slug}.md` : slug === 'index' ? '/' : `/${slug}/`;
98
102
  const href = (siteUrl ?? '') + path_;
99
103
  let description = truncateDescription(entry.data.description);
100
104
  // Mirrors Mintlify's own behavior: an OpenAPI operation page's
@@ -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
  }
@@ -692,8 +692,8 @@
692
692
  "proxy": {
693
693
  "default": true,
694
694
  "type": "boolean",
695
- "description": "Send \"Try it\" requests through writedocs' proxy, for APIs that don't allow calls from other sites (CORS). Default true.",
696
- "markdownDescription": "Send \"Try it\" requests through writedocs' proxy, for APIs that don't allow calls from other sites (CORS). Default true."
695
+ "description": "When the browser blocks a \"Try it\" request (the API doesn't allow calls from other sites - CORS), retry it through writedocs' proxy. Requests always go to the API directly first. Default true.",
696
+ "markdownDescription": "When the browser blocks a \"Try it\" request (the API doesn't allow calls from other sites - CORS), retry it through writedocs' proxy. Requests always go to the API directly first. Default true."
697
697
  }
698
698
  },
699
699
  "additionalProperties": false,