@writedocs/generator 0.9.1 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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;
@@ -566,6 +566,10 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
566
566
  --wd-text-muted: #64748b;
567
567
  --wd-border: #e2e8f0;
568
568
  --wd-surface: #f8fafc;
569
+ /* The browser's own controls - the page's scrollbar, a code block's -
570
+ drawn for the theme on screen. Without it they stayed light in dark
571
+ mode. */
572
+ color-scheme: light;
569
573
  /* Not mode-dependent (a site's chosen font doesn't change between
570
574
  light/dark) - set once here rather than duplicated in the
571
575
  :root[data-theme='dark'] block below. */
@@ -594,6 +598,7 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
594
598
  --wd-text-muted: #94a3b8;
595
599
  --wd-border: #1e293b;
596
600
  --wd-surface: #131a2b;
601
+ color-scheme: dark;
597
602
  }
598
603
  </style>
599
604
  {/* styles.fonts: local/externally-hosted font(s) (fontFaceRule() in
@@ -244,3 +244,47 @@ img.wd-icon-img {
244
244
  height: 1em;
245
245
  object-fit: contain;
246
246
  }
247
+
248
+ /* Scrollbars of the columns beside the page - the sidebar, the table of
249
+ contents, the mobile menu and its option lists: a thin pill in the
250
+ site's muted text color on no track, faint while the pointer is
251
+ elsewhere, clearer over the column, strongest on the pill itself.
252
+ Chromium and Safari draw it from the ::-webkit-scrollbar rules (the
253
+ standard scrollbar-color/-width would switch those off in Chromium, and
254
+ can't make it this thin); Firefox, which has only the standard ones,
255
+ gets its thin scrollbar in the same colors. */
256
+ :root {
257
+ --wd-scrollbar-thumb: color-mix(in srgb, var(--wd-text-muted) 22%, transparent);
258
+ --wd-scrollbar-thumb-near: color-mix(in srgb, var(--wd-text-muted) 45%, transparent);
259
+ --wd-scrollbar-thumb-active: color-mix(in srgb, var(--wd-text-muted) 70%, transparent);
260
+ }
261
+ :is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel)::-webkit-scrollbar {
262
+ width: 10px;
263
+ height: 10px;
264
+ }
265
+ :is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel)::-webkit-scrollbar-track {
266
+ background: transparent;
267
+ }
268
+ :is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel)::-webkit-scrollbar-thumb {
269
+ background: var(--wd-scrollbar-thumb);
270
+ /* A 6px pill with 2px of air on each side - off the column's border line. */
271
+ border: 2px solid transparent;
272
+ background-clip: padding-box;
273
+ border-radius: 999px;
274
+ }
275
+ :is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel):hover::-webkit-scrollbar-thumb {
276
+ background: var(--wd-scrollbar-thumb-near);
277
+ }
278
+ :is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel)::-webkit-scrollbar-thumb:hover,
279
+ :is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel)::-webkit-scrollbar-thumb:active {
280
+ background: var(--wd-scrollbar-thumb-active);
281
+ }
282
+ @supports not selector(::-webkit-scrollbar) {
283
+ :is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel) {
284
+ scrollbar-width: thin;
285
+ scrollbar-color: var(--wd-scrollbar-thumb) transparent;
286
+ }
287
+ :is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel):hover {
288
+ scrollbar-color: var(--wd-scrollbar-thumb-near) transparent;
289
+ }
290
+ }
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,