@writedocs/generator 0.9.1 → 0.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Static site generator for docs — a writedocs.json + MDX folder in, a static site out.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,8 @@
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
 
17
- const REFRESH = /<meta http-equiv="refresh" content="0;url=([^"]*)"\s*\/?>/i;
17
+ // Any delay: Astro waits 2 seconds on a 302's page (the automatic "/"), none on a 301's.
18
+ const REFRESH = /<meta http-equiv="refresh" content="\d+;\s*url=([^"]*)"\s*\/?>/i;
18
19
  const ASTRO_REDIRECT = /<title>Redirecting to:/;
19
20
  const CANONICAL = /<link rel="canonical" href="([^"]*)"\s*\/?>/i;
20
21
 
@@ -58,7 +58,9 @@ export function writeRedirectsFile(distDir, contentDir) {
58
58
  if (!hasExplicitRootRedirect && fs.existsSync(indexHtmlPath)) {
59
59
  const html = fs.readFileSync(indexHtmlPath, 'utf8');
60
60
  const match = html.match(/<meta http-equiv="refresh" content="\d+;\s*url=([^"]+)"/i);
61
- if (match) lines.push(`/ ${match[1]} 301`);
61
+ // 302, not 301: see [...slug].astro - a browser keeps a 301 for good,
62
+ // and "/" stops redirecting once the site gets a home page.
63
+ if (match) lines.push(`/ ${match[1]} 302`);
62
64
  }
63
65
 
64
66
  if (lines.length === 0) return;
package/src/lib/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { EXCLUDED_TOP_LEVEL_DIRS } from './pages.js';
3
+ import { EXCLUDED_TOP_LEVEL_DIRS, navPageId } from './pages.js';
4
4
  import matter from 'gray-matter';
5
5
  import { writedocsTempDir } from './writedocs-temp-dir.js';
6
6
  import { readableTextOn } from './color.js';
@@ -419,10 +419,27 @@ export function loadDocsConfig(contentDir: string): DocsConfig {
419
419
  // A no-op pass over ordinary navigation trees (no openapi groups) -
420
420
  // always run, rather than gated behind a global manifest check, since
421
421
  // there's no longer a single global spec to check for.
422
- result.data.navigation = expandOpenApiInNavigation(result.data.navigation, contentDir);
422
+ result.data.navigation = withPageIds(expandOpenApiInNavigation(result.data.navigation, contentDir));
423
423
  return result.data;
424
424
  }
425
425
 
426
+ /** The navigation with every page entry as its page id - "guides/index"
427
+ * becomes "guides" (navPageId() in lib/pages.js), so everything that
428
+ * matches entries against pages (routes, the sidebar, prev/next, llms.txt,
429
+ * the MCP index) finds a folder's index.mdx however writedocs.json names it. */
430
+ function withPageIds<T>(node: T): T {
431
+ if (Array.isArray(node)) return node.map((item) => (typeof item === 'string' ? navPageId(item) : withPageIds(item))) as T;
432
+ if (node && typeof node === 'object') {
433
+ return Object.fromEntries(
434
+ Object.entries(node).map(([key, value]) => [
435
+ key,
436
+ key === 'page' && typeof value === 'string' ? navPageId(value) : key === 'href' || key === 'openapi' ? value : withPageIds(value),
437
+ ])
438
+ ) as T;
439
+ }
440
+ return node;
441
+ }
442
+
426
443
  // --- Locating a page by file id, independent of its effective slug -----
427
444
  //
428
445
  // writedocs.json's `pages` arrays, and every helper above/below that walks
@@ -30,7 +30,7 @@ import fs from 'node:fs';
30
30
  import path from 'node:path';
31
31
  import matter from 'gray-matter';
32
32
  import { visit } from 'unist-util-visit';
33
- import { findAllPages, fileIdForPath, titleFromPath } from './pages.js';
33
+ import { findAllPages, fileIdForPath, titleFromPath, navPageId } from './pages.js';
34
34
  import { iconExists } from './icons.js';
35
35
  import { findUnknownComponents } from './mdx-unknown-components.js';
36
36
  import { findInteractiveComponents, simplifiedBuiltinMessage } from './mdx-inline-react.js';
@@ -400,16 +400,14 @@ export async function checkContent(contentDir, configText) {
400
400
  const locate = createJsonLocator(configText);
401
401
  const ids = new Set(pages.map(fileIdForPath));
402
402
  for (const ref of navigationReferences(config.navigation)) {
403
- if (ids.has(ref.id)) continue;
404
- const folderIndex = ref.id.endsWith('/index') && ids.has(ref.id.replace(/\/index$/, ''));
403
+ // "guides/index" and "guides" both name guides/index.mdx (navPageId()).
404
+ if (ids.has(navPageId(ref.id))) continue;
405
405
  errors.push(
406
406
  issue(
407
407
  'writedocs.json',
408
408
  locate(ref.path)?.line,
409
409
  `The navigation lists page "${ref.id}", but there's no page with that path.`,
410
- folderIndex
411
- ? `A folder's index page is listed by the folder's path - write "${ref.id.replace(/\/index$/, '')}".`
412
- : `Create ${ref.id}.mdx, or fix the path (it's relative to the folder writedocs.json is in, without the extension).`
410
+ `Create ${ref.id}.mdx, or fix the path (it's relative to the folder writedocs.json is in, without the extension).`
413
411
  )
414
412
  );
415
413
  }
package/src/lib/pages.js CHANGED
@@ -16,9 +16,16 @@ export const EXCLUDED_TOP_LEVEL_DIRS = new Set(['node_modules', 'dist', '.astro'
16
16
  /** A page's title when its frontmatter has none - Mintlify's rule, so a
17
17
  * migrated page that relied on it keeps the same title: the file name
18
18
  * without its extension, dashes and underscores as spaces, first letter
19
- * capitalized. `guides/getting-started.mdx` -> "Getting started". */
19
+ * capitalized. `guides/getting-started.mdx` -> "Getting started". A
20
+ * folder's index page is named for the folder (`api/index.mdx` -> "Api"),
21
+ * and the site's own index.mdx is "Home" - "Index" names neither. */
20
22
  export function titleFromPath(filePath) {
21
- const base = String(filePath).split(/[\\/]/).pop().replace(/\.mdx?$/i, '');
23
+ const parts = String(filePath).split(/[\\/]/).filter(Boolean);
24
+ let base = (parts.pop() ?? '').replace(/\.mdx?$/i, '');
25
+ if (base.toLowerCase() === 'index') {
26
+ if (parts.length === 0) return 'Home';
27
+ base = parts.pop();
28
+ }
22
29
  const words = base.replace(/[-_]+/g, ' ').trim();
23
30
  return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Untitled';
24
31
  }
@@ -61,15 +68,23 @@ function readIgnoreFile(contentDir) {
61
68
  * groups' `page`) - read straight from the file, since this runs before
62
69
  * (and independently of) config validation. Empty when there's no
63
70
  * readable writedocs.json. */
71
+ /** The page id a navigation entry names. "guides/index" and "guides" are
72
+ * the same page: a folder's index.mdx has the folder's id (fileIdForPath()
73
+ * below), and Mintlify projects write it either way. The root "index"
74
+ * stays as it is. */
75
+ export function navPageId(id) {
76
+ return id.endsWith('/index') ? id.slice(0, -'/index'.length) : id;
77
+ }
78
+
64
79
  /** Every page id a `navigation` lists, in the order a reader meets them -
65
80
  * top to bottom, each group's own `page` before its `pages` - without
66
81
  * repeats. */
67
82
  export function navigationPageOrder(navigation) {
68
83
  const ids = new Set();
69
84
  (function walk(node) {
70
- if (Array.isArray(node)) node.forEach((item) => (typeof item === 'string' ? ids.add(item) : walk(item)));
85
+ if (Array.isArray(node)) node.forEach((item) => (typeof item === 'string' ? ids.add(navPageId(item)) : walk(item)));
71
86
  else if (node && typeof node === 'object') {
72
- if (typeof node.page === 'string') ids.add(node.page);
87
+ if (typeof node.page === 'string') ids.add(navPageId(node.page));
73
88
  for (const [key, value] of Object.entries(node)) {
74
89
  if (key !== 'page' && key !== 'openapi' && key !== 'href') walk(value);
75
90
  }
@@ -158,7 +158,7 @@ export async function getStaticPaths() {
158
158
  // rootAlreadyClaimed rules out), so the ternary below always resolves
159
159
  // to the non-root branch.
160
160
  const rootRedirect = firstEntry
161
- ? [{ params: { slug: undefined }, props: { redirectTo: `/${normalizeEntryId(firstEntry.id)}/` } }]
161
+ ? [{ params: { slug: undefined }, props: { redirectTo: `/${normalizeEntryId(firstEntry.id)}/`, temporary: true } }]
162
162
  : [];
163
163
 
164
164
  // Tracks every file id a nav-driven route below already claims, so
@@ -259,6 +259,8 @@ export async function getStaticPaths() {
259
259
 
260
260
  interface Props {
261
261
  redirectTo?: string;
262
+ // The automatic "/" redirect: temporary, see below.
263
+ temporary?: boolean;
262
264
  entry?: DocsEntry;
263
265
  prev?: { slug: string; group: string | null } | null;
264
266
  next?: { slug: string; group: string | null } | null;
@@ -274,11 +276,15 @@ interface Props {
274
276
 
275
277
  const rawProps = Astro.props as Props;
276
278
  if (rawProps.redirectTo) {
277
- // 301, not Astro's default 302: in a static build the status only picks
278
- // the meta refresh delay (astro/dist/core/routing/3xx.js) - 2 seconds of
279
- // "Redirecting from..." text for a 302, none for a 301. Hosts that read
280
- // _redirects get a real 301 for "/" too (write-redirects-file.js).
281
- return Astro.redirect(rawProps.redirectTo, 301);
279
+ // The automatic "/" redirect is temporary (302): its target is just the
280
+ // navigation's first page, and it goes away once the site has a page at
281
+ // "/". A 301 is remembered by the browser for good - after that, "/" kept
282
+ // redirecting even with a home page in place, in `writedocs dev` (every
283
+ // project's preview shares localhost:4321) and for returning visitors.
284
+ // A frontmatter `url` page stays 301. In a static build the status only
285
+ // picks the refresh delay of Astro's redirect page, and the build rewrites
286
+ // those pages to redirect at once either way (rewrite-redirect-pages.js).
287
+ return Astro.redirect(rawProps.redirectTo, rawProps.temporary ? 302 : 301);
282
288
  }
283
289
  const {
284
290
  entry,