@writedocs/generator 0.9.3 → 0.10.0

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/astro.config.mjs CHANGED
@@ -38,6 +38,7 @@ import { writedocsTempDir, writedocsBuildStagingDir } from './src/lib/writedocs-
38
38
  import { stylesAssetFallback } from './src/lib/styles-asset-integration.js';
39
39
  import { mcpDevServer } from './src/lib/mcp-dev-integration.js';
40
40
  import { canonicalPath } from './src/lib/canonical-path.js';
41
+ import { folderAddresses } from './src/lib/folder-redirects.js';
41
42
  import { report } from './src/lib/cli-report.js';
42
43
  import {
43
44
  loadDocsConfig,
@@ -152,12 +153,14 @@ function collectNoindexIds(rootContentDir) {
152
153
  // redirect-only route [...slug].astro synthesizes (noindex, pointing at
153
154
  // the first page) - which doesn't belong in the sitemap either.
154
155
  let rootIsPage = false;
156
+ const pageIds = [];
155
157
 
156
158
  for (const relativeId of findAllPages(rootContentDir)) {
157
159
  const file = path.join(rootContentDir, relativeId);
158
160
  const { data } = matter(fs.readFileSync(file, 'utf-8'));
159
161
  const pageId = normalizeEntryId(data?.slug ?? relativeId.replace(/\.mdx?$/i, '').replace(/\/index$/, ''));
160
162
  if (pageId === 'index') rootIsPage = true;
163
+ pageIds.push(pageId);
161
164
  // Top-level `noindex` is Mintlify's spelling, and a Mintlify `hidden`
162
165
  // page is noindexed too - content.config.ts folds both into
163
166
  // `seo.noindex` the same way (an explicit value wins).
@@ -172,9 +175,13 @@ function collectNoindexIds(rootContentDir) {
172
175
  for (const file of walkMdFiles(generatedDocsDir)) {
173
176
  const { data } = matter(fs.readFileSync(file, 'utf-8'));
174
177
  if (data?.slug && normalizeEntryId(data.slug) === 'index') rootIsPage = true;
178
+ if (data?.slug) pageIds.push(normalizeEntryId(data.slug));
175
179
  if ((data?.seo?.noindex ?? data?.noindex ?? data?.hidden === true) && data.slug) ids.add(normalizeEntryId(data.slug));
176
180
  }
177
181
  if (!rootIsPage) ids.add('index');
182
+ // Folder addresses with no page of their own redirect to one under them
183
+ // (lib/folder-redirects.js) - redirects, so not in the sitemap either.
184
+ for (const folder of folderAddresses(pageIds)) ids.add(folder);
178
185
  return ids;
179
186
  }
180
187
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
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": {
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
  import { spawn } from 'node:child_process';
4
+ import { pathToFileURL } from 'node:url';
4
5
 
5
6
  /**
6
7
  * Locates the installed pagefind package by walking up node_modules
@@ -53,7 +54,77 @@ function resolvePagefindBin(packageRoot) {
53
54
  * Pagefind's output is captured, not printed: the CLI prints its own step
54
55
  * line, and Pagefind's text only when it fails (in the rejected Error).
55
56
  */
56
- export function runPagefind(siteDir, { packageRoot }) {
57
+ export async function runPagefind(siteDir, { packageRoot }) {
58
+ const groups = searchSpaces(siteDir);
59
+ if (groups) return indexBySpace(siteDir, packageRoot, groups);
60
+ return indexWholeSite(siteDir, packageRoot);
61
+ }
62
+
63
+ // Pages under a hidden navigation item name their search space in their
64
+ // markup ([...slug].astro, lib/hidden-sections.js).
65
+ const SPACE = /data-wd-search-space="([^"]+)"/;
66
+ const SEARCH_PUBLIC = /data-wd-search-public="true"/;
67
+
68
+ /** The site's pages by search space - `{ public, spaces }` - or null when
69
+ * no page is in a hidden space (the whole site is one index, as ever).
70
+ * Only pages Pagefind would index: those with a data-pagefind-body. */
71
+ function searchSpaces(siteDir) {
72
+ const publicPages = [];
73
+ const spaces = new Map();
74
+ const walk = (dir) => {
75
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
76
+ const full = path.join(dir, entry.name);
77
+ if (entry.isDirectory()) {
78
+ if (!['_astro', 'pagefind', 'pagefind-spaces'].includes(entry.name)) walk(full);
79
+ continue;
80
+ }
81
+ if (!entry.name.endsWith('.html')) continue;
82
+ const content = fs.readFileSync(full, 'utf8');
83
+ if (!content.includes('data-pagefind-body')) continue;
84
+ const page = { rel: path.relative(siteDir, full).split(path.sep).join('/'), content };
85
+ const space = SPACE.exec(content)?.[1];
86
+ if (!space) {
87
+ publicPages.push(page);
88
+ continue;
89
+ }
90
+ const group = spaces.get(space) ?? { searchPublic: false, pages: [] };
91
+ group.searchPublic ||= SEARCH_PUBLIC.test(content);
92
+ group.pages.push(page);
93
+ spaces.set(space, group);
94
+ }
95
+ };
96
+ walk(siteDir);
97
+ return spaces.size ? { public: publicPages, spaces } : null;
98
+ }
99
+
100
+ /** One index for the public pages (dist/pagefind/) and one per hidden
101
+ * space (dist/pagefind-spaces/<id>/) - so the public search never finds a
102
+ * hidden page, and a hidden space's search finds only its own pages, plus
103
+ * the public ones when it sets `searchPublic`. src/scripts/search.ts loads
104
+ * the index of the page's space. */
105
+ async function indexBySpace(siteDir, packageRoot, groups) {
106
+ const pagefind = await import(pathToFileURL(path.join(findPagefindDir(packageRoot), 'lib', 'index.js')).href);
107
+ const check = (result, what) => {
108
+ if (result.errors?.length) throw new Error(`pagefind (${what}): ${result.errors.join('; ')}`);
109
+ return result;
110
+ };
111
+ const write = async (pages, outputPath, what) => {
112
+ const { index } = check(await pagefind.createIndex({}), what);
113
+ for (const page of pages) check(await index.addHTMLFile({ sourcePath: page.rel, content: page.content }), `${what}: ${page.rel}`);
114
+ check(await index.writeFiles({ outputPath }), what);
115
+ };
116
+ try {
117
+ await write(groups.public, path.join(siteDir, 'pagefind'), 'public pages');
118
+ for (const [id, space] of groups.spaces) {
119
+ const pages = space.searchPublic ? [...space.pages, ...groups.public] : space.pages;
120
+ await write(pages, path.join(siteDir, 'pagefind-spaces', id), `hidden space ${id}`);
121
+ }
122
+ } finally {
123
+ await pagefind.close();
124
+ }
125
+ }
126
+
127
+ function indexWholeSite(siteDir, packageRoot) {
57
128
  const bin = resolvePagefindBin(packageRoot);
58
129
  return new Promise((resolve, reject) => {
59
130
  const child = spawn(process.execPath, [bin, '--site', siteDir, '--quiet'], {
@@ -63,6 +63,17 @@ export function writeRedirectsFile(distDir, contentDir) {
63
63
  if (match) lines.push(`/ ${match[1]} 302`);
64
64
  }
65
65
 
66
+ // Folder addresses redirecting to the first page under them
67
+ // (lib/folder-redirects.js). Read back from the built pages like "/"
68
+ // above: Astro writes a temporary (302) redirect's page with a 2-second
69
+ // refresh and a permanent one's with 0 - writedocs.json's own redirects
70
+ // and `url` pages are permanent, so the 2-second ones are exactly the
71
+ // automatic folder redirects. (Read before rewrite-redirect-pages.js makes
72
+ // every redirect page go at once.)
73
+ for (const { url, target } of temporaryRedirectPages(distDir)) {
74
+ lines.push(`${url} ${target} 302`, `${url.replace(/\/$/, '')} ${target} 302`);
75
+ }
76
+
66
77
  if (lines.length === 0) return;
67
78
 
68
79
  const generated = [
@@ -81,3 +92,25 @@ export function writeRedirectsFile(distDir, contentDir) {
81
92
  const existing = fs.existsSync(redirectsPath) ? fs.readFileSync(redirectsPath, 'utf8').replace(/\s*$/, '\n\n') : '';
82
93
  fs.writeFileSync(redirectsPath, existing + generated);
83
94
  }
95
+
96
+ /** Astro's pages for temporary redirects below the site's root, as
97
+ * `{ url: "/docs/creator/", target }`. */
98
+ function temporaryRedirectPages(distDir) {
99
+ const found = [];
100
+ const walk = (dir) => {
101
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
102
+ const full = path.join(dir, entry.name);
103
+ if (entry.isDirectory()) {
104
+ if (!['_astro', 'pagefind', 'pagefind-spaces'].includes(entry.name)) walk(full);
105
+ continue;
106
+ }
107
+ if (entry.name !== 'index.html' || dir === distDir) continue;
108
+ const html = fs.readFileSync(full, 'utf8');
109
+ if (!html.includes('<title>Redirecting to:')) continue;
110
+ const match = html.match(/<meta http-equiv="refresh" content="2;\s*url=([^"]+)"/i);
111
+ if (match) found.push({ url: `/${path.relative(distDir, dir).split(path.sep).join('/')}/`, target: match[1] });
112
+ }
113
+ };
114
+ walk(distDir);
115
+ return found.sort((a, b) => a.url.localeCompare(b.url));
116
+ }
@@ -13,6 +13,7 @@ import {
13
13
  type OpenApiOperation,
14
14
  } from '../lib/openapi-render';
15
15
  import { parseOpenApiRef } from '../lib/openapi-ref.js';
16
+ import { descriptionHtml } from '../lib/openapi-markdown';
16
17
 
17
18
  interface Props {
18
19
  operation: string; // "METHOD /path", matching a page's `openapi` frontmatter
@@ -37,6 +38,12 @@ const requestRows = requestSchema ? schemaRows(requestSchema) : [];
37
38
 
38
39
  const responseEntries = op ? Object.entries(op.responses) : [];
39
40
  const segments = op ? pathSegments(op.path) : [];
41
+
42
+ // Descriptions are Markdown in the spec - rendered here, once each
43
+ // (lib/openapi-markdown.ts), and set as HTML below.
44
+ const operationHtml = await descriptionHtml(op?.description);
45
+ const paramHtml = new Map(await Promise.all((op?.parameters ?? []).map(async (p) => [p, await descriptionHtml(p.description)] as const)));
46
+ const responseHtml = new Map(await Promise.all(responseEntries.map(async ([status, r]) => [status, await descriptionHtml(r.description)] as const)));
40
47
  ---
41
48
 
42
49
  {!op && (
@@ -57,7 +64,7 @@ const segments = op ? pathSegments(op.path) : [];
57
64
  )}
58
65
  </code>
59
66
  </div>
60
- {op.description && <p class="wd-api-description">{op.description}</p>}
67
+ {operationHtml && <div class="wd-api-description wd-api-md" set:html={operationHtml} />}
61
68
 
62
69
  {headerParams.length > 0 && (
63
70
  <section class="wd-api-section">
@@ -70,7 +77,7 @@ const segments = op ? pathSegments(op.path) : [];
70
77
  <span class="wd-api-pill wd-api-pill-type">{p.schema?.type ?? 'string'}</span>
71
78
  {p.required && <span class="wd-api-pill wd-api-pill-required">required</span>}
72
79
  </div>
73
- {p.description && <p class="wd-api-param-desc">{p.description}</p>}
80
+ {paramHtml.get(p) && <div class="wd-api-param-desc wd-api-md" set:html={paramHtml.get(p)} />}
74
81
  </div>
75
82
  ))}
76
83
  </div>
@@ -88,7 +95,7 @@ const segments = op ? pathSegments(op.path) : [];
88
95
  <span class="wd-api-pill wd-api-pill-type">{p.schema?.type ?? 'string'}</span>
89
96
  {p.required && <span class="wd-api-pill wd-api-pill-required">required</span>}
90
97
  </div>
91
- {p.description && <p class="wd-api-param-desc">{p.description}</p>}
98
+ {paramHtml.get(p) && <div class="wd-api-param-desc wd-api-md" set:html={paramHtml.get(p)} />}
92
99
  </div>
93
100
  ))}
94
101
  </div>
@@ -106,7 +113,7 @@ const segments = op ? pathSegments(op.path) : [];
106
113
  <span class="wd-api-pill wd-api-pill-type">{p.schema?.type ?? 'string'}</span>
107
114
  {p.required && <span class="wd-api-pill wd-api-pill-required">required</span>}
108
115
  </div>
109
- {p.description && <p class="wd-api-param-desc">{p.description}</p>}
116
+ {paramHtml.get(p) && <div class="wd-api-param-desc wd-api-md" set:html={paramHtml.get(p)} />}
110
117
  </div>
111
118
  ))}
112
119
  </div>
@@ -132,8 +139,8 @@ const segments = op ? pathSegments(op.path) : [];
132
139
  const rows = schema ? schemaRows(schema) : [];
133
140
  return (
134
141
  <Tab title={status}>
135
- {resp.description && (
136
- <p class="wd-api-param-desc-inline wd-api-response-desc">{resp.description}</p>
142
+ {responseHtml.get(status) && (
143
+ <div class="wd-api-param-desc-inline wd-api-response-desc wd-api-md" set:html={responseHtml.get(status)} />
137
144
  )}
138
145
  {rows.length > 0 && (
139
146
  <div class="wd-api-param-list wd-api-param-list-nested">
@@ -282,4 +289,38 @@ const segments = op ? pathSegments(op.path) : [];
282
289
  .wd-api-response-desc {
283
290
  margin: 0 0 0.75rem;
284
291
  }
292
+ /* A description from the spec, rendered from its Markdown
293
+ (lib/openapi-markdown.ts) - paragraphs, lists, code and tables sized
294
+ to sit under a field. A wide table scrolls sideways inside its own box
295
+ rather than widening the page. */
296
+ .wd-api-md > :first-child {
297
+ margin-top: 0;
298
+ }
299
+ .wd-api-md > :last-child {
300
+ margin-bottom: 0;
301
+ }
302
+ .wd-api-md p,
303
+ .wd-api-md ul,
304
+ .wd-api-md ol {
305
+ margin: 0 0 0.6em;
306
+ }
307
+ .wd-api-md ul,
308
+ .wd-api-md ol {
309
+ padding-left: 1.25rem;
310
+ }
311
+ .wd-api-md table {
312
+ display: block;
313
+ max-width: 100%;
314
+ overflow-x: auto;
315
+ border-collapse: collapse;
316
+ margin: 0.6rem 0;
317
+ font-size: 0.85rem;
318
+ }
319
+ .wd-api-md th,
320
+ .wd-api-md td {
321
+ padding: 0.4rem 0.75rem;
322
+ }
323
+ .wd-api-md a {
324
+ color: var(--wd-primary);
325
+ }
285
326
  </style>
@@ -20,6 +20,7 @@
20
20
  // component handles every level."
21
21
  import Expandable from './Expandable.astro';
22
22
  import type { SchemaRow } from '../lib/openapi-render';
23
+ import { descriptionHtml } from '../lib/openapi-markdown';
23
24
 
24
25
  interface Props {
25
26
  rows: SchemaRow[];
@@ -33,17 +34,19 @@ interface Props {
33
34
  showRequired?: boolean;
34
35
  }
35
36
  const { rows, showRequired = true } = Astro.props as Props;
37
+ // Descriptions are Markdown in the spec (lib/openapi-markdown.ts).
38
+ const html = await Promise.all(rows.map((row) => descriptionHtml(row.description)));
36
39
  ---
37
40
 
38
41
  {
39
- rows.map((row) => (
42
+ rows.map((row, i) => (
40
43
  <div class="wd-api-param">
41
44
  <div class="wd-api-param-head">
42
45
  <span class="wd-api-param-name">{row.name}</span>
43
46
  <span class="wd-api-pill wd-api-pill-type">{row.type}</span>
44
47
  {showRequired && row.required && <span class="wd-api-pill wd-api-pill-required">required</span>}
45
48
  </div>
46
- {row.description && <p class="wd-api-param-desc">{row.description}</p>}
49
+ {html[i] && <div class="wd-api-param-desc wd-api-md" set:html={html[i]} />}
47
50
  {row.children.length > 0 && (
48
51
  <Expandable title={`${row.name} properties`} defaultOpen={false}>
49
52
  <Astro.self rows={row.children} showRequired={showRequired} />
@@ -4,8 +4,10 @@ import { extraClasses } from './class-names';
4
4
  // (Callout, Card, Accordion, ...), which are all block-level and get
5
5
  // their own line, a Hint sits mid-sentence: `word <Hint tip="...">this
6
6
  // bit</Hint> continues`. That's why this renders a <span>, not a <div>,
7
- // and why its own trigger area is exactly its slotted text - no icon,
8
- // no padding box drawing attention to itself the way a Callout does.
7
+ // and why its own trigger area is its slotted text plus, by default, a
8
+ // small info icon after it - a dotted underline and that icon are the
9
+ // whole cue, no padding box drawing attention to itself the way a Callout
10
+ // does. `icon={false}` leaves just the underlined text.
9
11
  //
10
12
  // tip is a plain string prop (not a slot) deliberately - the tooltip
11
13
  // bubble itself needs to be one line of plain text CSS can center/
@@ -24,13 +26,20 @@ interface Props {
24
26
  headline?: string;
25
27
  cta?: string;
26
28
  href?: string;
29
+ /** The small info icon after the text. On unless set to false. */
30
+ icon?: boolean;
27
31
  }
28
- const { tip, headline, cta, href } = Astro.props as Props;
32
+ const { tip, headline, cta, href, icon = true } = Astro.props as Props;
29
33
  const hasLink = Boolean(cta && href);
30
34
  ---
31
35
 
32
36
  <span class:list={["wd-hint", { "wd-hint-rich": Boolean(headline) || hasLink }, extraClasses(Astro.props)]} tabindex="0">
33
- <span class="wd-hint-trigger"><slot /></span>
37
+ <span class="wd-hint-trigger"><slot /></span>{icon && (
38
+ <svg class="wd-hint-icon" viewBox="0 0 24 24" aria-hidden="true">
39
+ <circle cx="12" cy="12" r="9.5" />
40
+ <path d="M12 16.5v-5M12 8h.01" />
41
+ </svg>
42
+ )}
34
43
  <span class="wd-hint-tooltip" role="tooltip">
35
44
  {headline && <span class="wd-hint-headline">{headline}</span>}
36
45
  <span class="wd-hint-tip">{tip}</span>
@@ -52,7 +61,39 @@ const hasLink = Boolean(cta && href);
52
61
  hint here at all, since nothing about plain underlined text
53
62
  otherwise signals "hover me" the way an icon or button would. */
54
63
  cursor: help;
55
- border-bottom: 1px solid var(--wd-text);
64
+ }
65
+ /* A dotted underline under the text, drawn by the font's own underline
66
+ rather than a border, so it follows the text across a line break and
67
+ sits clear of descenders. */
68
+ .wd-hint-trigger {
69
+ text-decoration-line: underline;
70
+ text-decoration-style: dotted;
71
+ text-decoration-thickness: 2px;
72
+ text-decoration-color: color-mix(in srgb, currentColor 55%, transparent);
73
+ text-underline-offset: 0.22em;
74
+ }
75
+ .wd-hint:hover .wd-hint-trigger,
76
+ .wd-hint:focus-visible .wd-hint-trigger {
77
+ text-decoration-color: currentColor;
78
+ }
79
+ /* Smaller than the text, a little clear of it, in the muted color. */
80
+ .wd-hint-icon {
81
+ display: inline-block;
82
+ width: 0.72em;
83
+ height: 0.72em;
84
+ margin-left: 0.22em;
85
+ vertical-align: -0.04em;
86
+ fill: none;
87
+ stroke: currentColor;
88
+ stroke-width: 2.2;
89
+ stroke-linecap: round;
90
+ stroke-linejoin: round;
91
+ color: var(--wd-text-muted);
92
+ transition: color 0.15s ease;
93
+ }
94
+ .wd-hint:hover .wd-hint-icon,
95
+ .wd-hint:focus-visible .wd-hint-icon {
96
+ color: var(--wd-text);
56
97
  }
57
98
  .wd-hint-tooltip {
58
99
  position: absolute;
@@ -7,6 +7,7 @@ interface Props {
7
7
  headline?: string;
8
8
  cta?: string;
9
9
  href?: string;
10
+ icon?: boolean;
10
11
  }
11
12
  const props = Astro.props as Props;
12
13
  ---
@@ -98,9 +98,9 @@ const showMenu = hasMobileMenu({ sidebar: showSidebarCol, selectors, globalDropd
98
98
  </button>
99
99
  </div>
100
100
  <div class="wd-mobile-menu-scroll">
101
- {(selectors.length > 0 || globalDropdowns.length > 0) && (
101
+ {(selectors.some((sel) => !sel.hidden) || globalDropdowns.length > 0) && (
102
102
  <div class="wd-mobile-menu-selectors">
103
- {selectors.map((sel) => {
103
+ {selectors.filter((sel) => !sel.hidden).map((sel) => {
104
104
  const current = sel.options.find((o) => o.active) ?? sel.options[0];
105
105
  return (
106
106
  <details class="wd-mobile-accordion" name="wd-mobile-accordion">
@@ -59,7 +59,7 @@ const { config, selectors, globalDropdowns, showSidebarCol, logoLight, logoDark,
59
59
  const placements = selectorPlacements(selectors, { sidebar: showSidebarCol });
60
60
  const showMenuButton = hasMobileMenu({ sidebar: showSidebarCol, selectors, globalDropdowns });
61
61
  const switcherSelectors = selectors.filter((_, i) => placements[i] === 'topbar');
62
- const tabSelectors = selectors.filter((sel) => sel.kind === 'tab');
62
+ const tabSelectors = selectors.filter((_, i) => placements[i] === 'tabs');
63
63
  // One row per level of tabs: a tab holding its own tabs gets a second row
64
64
  // underneath, with that tab's tabs. The global dropdowns stay in the first
65
65
  // row, which renders on its own when there are only those.
@@ -32,7 +32,8 @@ const navLinkSchema = z.object({ label: z.string(), href: z.string() }).strict()
32
32
  const navItemSchema = z.lazy(
33
33
  () => z.union([navPageSchema, navGroupSchema, navLinkSchema])
34
34
  );
35
- function withChildren(base) {
35
+ function withChildren(ownFields) {
36
+ const base = { ...ownFields, hidden: z.boolean().optional(), searchPublic: z.boolean().optional() };
36
37
  return z.union([
37
38
  z.object({ ...base, pages: z.array(navItemSchema) }).strict(),
38
39
  z.object({ ...base, tabs: z.array(tabSchema).min(1) }).strict(),
@@ -97,6 +98,14 @@ const navigationSchema = z.union([
97
98
  continue;
98
99
  }
99
100
  items.forEach((item, i) => {
101
+ const own2 = item;
102
+ if (own2?.searchPublic === true && own2.hidden !== true) {
103
+ ctx.addIssue({
104
+ code: "custom",
105
+ path: [...path, key, i, "searchPublic"],
106
+ message: '`searchPublic` only applies to a hidden item - add "hidden": true, or remove `searchPublic`.'
107
+ });
108
+ }
100
109
  const inner = key === "languages" ? String(item?.language ?? "") : language;
101
110
  const tab = item?.tab;
102
111
  walk(item, [...path, key, i], inner, key === "tabs" ? [...tabs, String(tab ?? "")] : tabs);
@@ -129,11 +129,17 @@ export type NavChildren =
129
129
  | { products: ProductItem[] }
130
130
  | { href: string };
131
131
 
132
- export type TabItem = { tab: string; icon?: string } & NavChildren;
133
- export type VersionItem = { version: string; label?: string; tag?: string; default?: boolean } & NavChildren;
134
- export type LanguageItem = { language: string; label?: string } & NavChildren;
135
- export type DropdownItem = { dropdown: string; icon?: string } & NavChildren;
136
- export type ProductItem = { product: string; icon?: string; description?: string } & NavChildren;
132
+ /** Any navigation item can be hidden: reachable only by its address - no
133
+ * switcher, tab or menu lists it, and its pages search only among
134
+ * themselves (plus the public ones, with `searchPublic`). See
135
+ * lib/hidden-sections.js. */
136
+ export type Hideable = { hidden?: boolean; searchPublic?: boolean };
137
+
138
+ export type TabItem = { tab: string; icon?: string } & Hideable & NavChildren;
139
+ export type VersionItem = { version: string; label?: string; tag?: string; default?: boolean } & Hideable & NavChildren;
140
+ export type LanguageItem = { language: string; label?: string } & Hideable & NavChildren;
141
+ export type DropdownItem = { dropdown: string; icon?: string } & Hideable & NavChildren;
142
+ export type ProductItem = { product: string; icon?: string; description?: string } & Hideable & NavChildren;
137
143
 
138
144
  // `.strict()` on every variant is load-bearing, not decoration: Zod
139
145
  // objects silently strip unrecognized keys by default, so without it a
@@ -142,7 +148,8 @@ export type ProductItem = { product: string; icon?: string; description?: string
142
148
  // rejected - defeating the entire "exactly one child kind per level"
143
149
  // rule this schema exists to enforce (mirroring Mintlify's own "a tab
144
150
  // cannot contain both anchors and groups at the same level").
145
- function withChildren<Base extends z.ZodRawShape>(base: Base) {
151
+ function withChildren<Base extends z.ZodRawShape>(ownFields: Base) {
152
+ const base = { ...ownFields, hidden: z.boolean().optional(), searchPublic: z.boolean().optional() };
146
153
  return z.union([
147
154
  z.object({ ...base, pages: z.array(navItemSchema) }).strict(),
148
155
  z.object({ ...base, tabs: z.array(tabSchema).min(1) }).strict(),
@@ -247,6 +254,14 @@ const navigationSchema = z.union([
247
254
  continue;
248
255
  }
249
256
  items.forEach((item, i) => {
257
+ const own = item as { hidden?: unknown; searchPublic?: unknown };
258
+ if (own?.searchPublic === true && own.hidden !== true) {
259
+ ctx.addIssue({
260
+ code: 'custom',
261
+ path: [...path, key, i, 'searchPublic'],
262
+ message: '`searchPublic` only applies to a hidden item - add "hidden": true, or remove `searchPublic`.',
263
+ });
264
+ }
250
265
  const inner = key === 'languages' ? String((item as { language?: unknown })?.language ?? '') : language;
251
266
  const tab = (item as { tab?: unknown })?.tab;
252
267
  walk(item, [...path, key, i], inner, key === 'tabs' ? [...tabs, String(tab ?? '')] : tabs);
package/src/lib/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { EXCLUDED_TOP_LEVEL_DIRS, navPageId } from './pages.js';
4
+ import { isHidden } from './hidden-sections.js';
4
5
  import matter from 'gray-matter';
5
6
  import { writedocsTempDir } from './writedocs-temp-dir.js';
6
7
  import { readableTextOn } from './color.js';
@@ -787,6 +788,8 @@ export function resolveGlobalDropdowns(navigation: NavigationConfig): DropdownIt
787
788
  * `{ tab: "Status", href: "..." }`). */
788
789
  function firstSlugAmong(items: Container[]): string | null {
789
790
  for (const item of items) {
791
+ // A hidden item is reached only by its address - never by default.
792
+ if (isHidden(item)) continue;
790
793
  const slug = firstSlugOf(item);
791
794
  if (slug !== null) return slug;
792
795
  }
@@ -944,6 +947,11 @@ export interface SelectorOption {
944
947
  export interface Selector {
945
948
  kind: PathSegment['kind'];
946
949
  options: SelectorOption[];
950
+ // The page sits inside a hidden item on this level: the level isn't
951
+ // shown (its switcher would list the other items). Kept in the list,
952
+ // not dropped, so the levels after it are placed by their real parent
953
+ // (lib/selector-placement.js).
954
+ hidden?: boolean;
947
955
  }
948
956
 
949
957
  /** An option's own `dropdowns` menu, if it has one (see SelectorOption.dropdown
@@ -958,12 +966,16 @@ function dropdownMenuOf(
958
966
  activeSegment: PathSegment | undefined
959
967
  ): SelectorOption[] | undefined {
960
968
  if (!('dropdowns' in node)) return undefined;
961
- return node.dropdowns.map((d, i) => ({
962
- label: d.dropdown,
963
- icon: iconOf(d),
964
- href: 'href' in d ? d.href : hrefForSlug(firstSlugOf(d) ?? 'index'),
965
- active: activeSegment?.kind === 'dropdown' && activeSegment.index === i,
966
- }));
969
+ return node.dropdowns
970
+ .map((d, i) => ({
971
+ label: d.dropdown,
972
+ icon: iconOf(d),
973
+ href: 'href' in d ? d.href : hrefForSlug(firstSlugOf(d) ?? 'index'),
974
+ active: activeSegment?.kind === 'dropdown' && activeSegment.index === i,
975
+ hidden: isHidden(d),
976
+ }))
977
+ .filter((d) => !d.hidden)
978
+ .map(({ hidden: _hidden, ...option }) => option);
967
979
  }
968
980
 
969
981
  /** `activePagePosition` is the reader's current page's own index within
@@ -986,7 +998,10 @@ export function buildSelectors(
986
998
  const remainingPath = path.slice(segIndex + 1);
987
999
  return {
988
1000
  kind: segment.kind,
989
- options: (segment.items as NamedContainer[]).map((item, i) => {
1001
+ hidden: isHidden(segment.items[segment.index]),
1002
+ options: (segment.items as NamedContainer[]).flatMap((item, i) => {
1003
+ // A hidden item is never offered - not even to its own pages.
1004
+ if (isHidden(item)) return [];
990
1005
  const isActive = i === segment.index;
991
1006
  const equivalentSlug = preservesPosition
992
1007
  ? equivalentPageIn(item as Container, remainingPath, activePagePosition)
@@ -411,6 +411,20 @@ export async function checkContent(contentDir, configText) {
411
411
  )
412
412
  );
413
413
  }
414
+ // Every top-level item hidden, and no home page: `/` never redirects into a
415
+ // hidden item (lib/hidden-sections.js), so the site's address has nothing.
416
+ const nav = config.navigation;
417
+ const topKey = nav && !Array.isArray(nav) ? ['tabs', 'versions', 'languages', 'dropdowns', 'products'].find((k) => Array.isArray(nav[k])) : null;
418
+ if (topKey && nav[topKey].length && nav[topKey].every((item) => item?.hidden === true || 'href' in (item ?? {})) && !ids.has('index')) {
419
+ warnings.push(
420
+ issue(
421
+ 'writedocs.json',
422
+ locate(['navigation', topKey])?.line,
423
+ `Every item in navigation.${topKey} is hidden and there's no home page - the site's own address (/) shows "not found".`,
424
+ 'Add an index.mdx at the project root for a home page, or leave one item not hidden.'
425
+ )
426
+ );
427
+ }
414
428
  // A redirect is one exact path - a Mintlify-style pattern (`/old/:slug`,
415
429
  // `/old/*`) becomes a literal page path, and the build fails on it.
416
430
  (Array.isArray(config.redirects) ? config.redirects : []).forEach((r, i) => {
@@ -0,0 +1,47 @@
1
+ // Folder addresses - `/docs/creator/` when pages live at `/docs/creator/...`
2
+ // but none at `/docs/creator/` itself - redirect to the first page under
3
+ // them, as Mintlify does, instead of showing "not found". The same idea as
4
+ // the automatic `/` redirect, one level down.
5
+ //
6
+ // "First" is the reader's order: [...slug].astro passes the pages in
7
+ // navigation order, the public ones before those under a hidden item
8
+ // (lib/hidden-sections.js) and pages outside the navigation last - so a
9
+ // folder with both public and hidden pages under it goes to a public one.
10
+ // A real page or a writedocs.json redirect at the address always wins.
11
+ //
12
+ // Temporary (302), like `/`: a browser doesn't keep it, so a page added at
13
+ // that address later is seen. Also used by `writedocs broken-links` (a link
14
+ // to a folder address leads somewhere) and the sitemap (which leaves them
15
+ // out, like every redirect).
16
+ //
17
+ // Slugs are page addresses without their slashes: "docs/creator/home".
18
+
19
+ /** First path segments writedocs itself serves - never a folder redirect. */
20
+ export const RESERVED_FOLDERS = new Set(['_astro', 'pagefind', 'pagefind-spaces', 'llms', 'mcp']);
21
+
22
+ /** Every folder above a page: "a/b/c" -> ["a", "a/b"]. */
23
+ export function foldersOf(slug) {
24
+ const parts = slug.split('/').filter(Boolean);
25
+ return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/'));
26
+ }
27
+
28
+ /** The folder redirects for a site: folder -> the page it goes to.
29
+ * `targets` - the pages, in the order to prefer them; `taken` - every
30
+ * address already served (pages, redirects, `url` pages). */
31
+ export function folderRedirects(targets, taken = new Set()) {
32
+ const redirects = new Map();
33
+ for (const slug of targets) {
34
+ if (slug === 'index') continue;
35
+ for (const folder of foldersOf(slug)) {
36
+ if (redirects.has(folder) || taken.has(folder) || RESERVED_FOLDERS.has(folder.split('/')[0])) continue;
37
+ redirects.set(folder, slug);
38
+ }
39
+ }
40
+ return redirects;
41
+ }
42
+
43
+ /** Just the folder addresses, for checks that don't need the targets. */
44
+ export function folderAddresses(pageSlugs) {
45
+ const pages = new Set(pageSlugs);
46
+ return new Set([...folderRedirects(pageSlugs, pages).keys()]);
47
+ }