@writedocs/generator 0.9.0 → 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.0",
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": {
package/src/cli/build.js CHANGED
@@ -6,6 +6,8 @@ import { runPagefind } from './run-pagefind.js';
6
6
  import { preflightCheck, sameDriveCheck, writableInstallCheck } from './preflight.js';
7
7
  import { generateApiPages } from './generate-api-pages.js';
8
8
  import { writeRedirectsFile } from './write-redirects-file.js';
9
+ import { rewriteRedirectPages } from './rewrite-redirect-pages.js';
10
+ import { readConfigText } from '../lib/config-file.js';
9
11
  import { writeMcpFiles } from './write-mcp-files.js';
10
12
  import { pagesWithoutStyles } from './check-built-styles.js';
11
13
  import { writedocsBuildStagingDir } from '../lib/writedocs-temp-dir.js';
@@ -128,6 +130,10 @@ export async function runBuild({ contentDir, packageRoot, verbose = false }) {
128
130
  // - this turns those into real instant edge redirects on hosts that read
129
131
  // a `_redirects` file (Cloudflare Pages, Netlify), purely additively.
130
132
  writeRedirectsFile(distDir, contentDir);
133
+ // Every other host follows Astro's redirect pages - rewritten so the
134
+ // reader never sees one (rewrite-redirect-pages.js).
135
+ const siteConfig = JSON.parse(readConfigText(contentDir));
136
+ rewriteRedirectPages(distDir, { background: siteConfig.styles?.background?.colors });
131
137
  // The site's MCP server at /mcp: mcp-index.json is already in dist/ (an
132
138
  // Astro route); this adds the Cloudflare _worker.js that serves it - see
133
139
  // write-mcp-files.js for every host.
@@ -0,0 +1,85 @@
1
+ // Astro's static build writes every redirect - the site's `/` when no page
2
+ // sits there, and writedocs.json's `redirects` - as a page with a 0-second
3
+ // <meta http-equiv="refresh">. That page has no styles: the browser paints
4
+ // it (white, with a "Redirecting from / to ..." link) before following the
5
+ // refresh - a visible flash on every host that doesn't redirect at the edge
6
+ // (Cloudflare Pages and Netlify do, from _redirects - write-redirects-file.js).
7
+ //
8
+ // After the build, each of those pages is rewritten so there's nothing to
9
+ // see: a script at the top of <head> redirects before anything is painted
10
+ // (keeping the query string and #anchor, which a meta refresh drops), the
11
+ // page is the site's own background color in the reader's theme, and the
12
+ // link is there for screen readers and as the no-script fallback, not on
13
+ // screen. The meta refresh stays for browsers without JavaScript.
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
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;
19
+ const ASTRO_REDIRECT = /<title>Redirecting to:/;
20
+ const CANONICAL = /<link rel="canonical" href="([^"]*)"\s*\/?>/i;
21
+
22
+ const DEFAULT_LIGHT = '#ffffff';
23
+ const DEFAULT_DARK = '#0b1120';
24
+
25
+ function decodeAttr(value) {
26
+ return value.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
27
+ }
28
+ function encodeAttr(value) {
29
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
30
+ }
31
+ /** A color from writedocs.json, only if it's safe inside a <style>. */
32
+ function cssColor(value, fallback) {
33
+ return typeof value === 'string' && /^[#a-zA-Z0-9(),.%\s-]{1,64}$/.test(value) ? value : fallback;
34
+ }
35
+
36
+ /** The page for a redirect to `target`. */
37
+ export function redirectPageHtml({ target, canonical = null, background = {} }) {
38
+ const light = cssColor(background.light, DEFAULT_LIGHT);
39
+ const dark = cssColor(background.dark, DEFAULT_DARK);
40
+ // Inside <script>: a JSON string, with "<" escaped so a target can't end the script.
41
+ const js = JSON.stringify(target).replace(/</g, '\\u003c');
42
+ const attr = encodeAttr(target);
43
+ return [
44
+ '<!doctype html>',
45
+ '<html lang="en">',
46
+ '<head>',
47
+ '<meta charset="utf-8">',
48
+ `<script>location.replace(${js} + location.search + location.hash)</script>`,
49
+ `<meta http-equiv="refresh" content="0;url=${attr}">`,
50
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
51
+ '<meta name="robots" content="noindex">',
52
+ canonical ? `<link rel="canonical" href="${encodeAttr(canonical)}">` : '',
53
+ '<title>Redirecting…</title>',
54
+ // Same theme choice as every page (BaseLayout.astro): the stored one, else the system's.
55
+ "<script>try{var t=localStorage.getItem('wd-theme');if(t!=='light'&&t!=='dark')t=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';document.documentElement.dataset.theme=t}catch(e){}</script>",
56
+ `<style>html{background:${light};color-scheme:light}html[data-theme=dark]{background:${dark};color-scheme:dark}@media (prefers-color-scheme:dark){html:not([data-theme]){background:${dark};color-scheme:dark}}body{margin:0}a{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}</style>`,
57
+ '</head>',
58
+ `<body><a href="${attr}">Continue to ${attr}</a></body>`,
59
+ '</html>',
60
+ '',
61
+ ].filter(Boolean).join('\n');
62
+ }
63
+
64
+ /** Rewrites every Astro redirect page under `distDir`. Returns how many. */
65
+ export function rewriteRedirectPages(distDir, { background } = {}) {
66
+ let count = 0;
67
+ const walk = (dir) => {
68
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
69
+ const full = path.join(dir, entry.name);
70
+ if (entry.isDirectory()) {
71
+ if (entry.name !== '_astro' && entry.name !== 'pagefind') walk(full);
72
+ continue;
73
+ }
74
+ if (!entry.name.endsWith('.html')) continue;
75
+ const html = fs.readFileSync(full, 'utf8');
76
+ const refresh = REFRESH.exec(html);
77
+ if (!refresh || !ASTRO_REDIRECT.test(html)) continue;
78
+ const canonical = CANONICAL.exec(html);
79
+ fs.writeFileSync(full, redirectPageHtml({ target: decodeAttr(refresh[1]), canonical: canonical ? decodeAttr(canonical[1]) : null, background }));
80
+ count++;
81
+ }
82
+ };
83
+ walk(distDir);
84
+ return count;
85
+ }
@@ -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;
@@ -210,6 +210,9 @@ const hasDropdown = items.length > 0 && !single;
210
210
  border: 1px solid var(--wd-border);
211
211
  border-radius: 0.4rem;
212
212
  flex-shrink: 0;
213
+ /* Right-aligned on its own line too (a long title on a phone sends it
214
+ below the title) - its menu opens leftwards, and stays on screen. */
215
+ margin-left: auto;
213
216
  }
214
217
  /* .wd-copy-page is this instance's own class on the .wd-dropdown div
215
218
  wrapping the caret button + menu (see BaseLayout.astro's shared
@@ -31,6 +31,7 @@
31
31
  // comment for the fuller rationale.
32
32
  import { isExternalHref, type DocsConfig, type Selector, type GlobalDropdownView } from '../../lib/config';
33
33
  import AppIcon from '../../components/AppIcon.astro';
34
+ import { hasMobileMenu } from '../../lib/selector-placement.js';
34
35
  import '../styles/dropdown.css';
35
36
  import '../styles/topbar.css';
36
37
  import '../styles/mobile-menu.css';
@@ -48,8 +49,11 @@ interface Props {
48
49
  brandLabel?: string;
49
50
  }
50
51
  const { config, selectors, globalDropdowns, showSidebarCol, logoLight, logoDark, brandLabel } = Astro.props as Props;
52
+ // A page without a sidebar (`mode: custom`) still gets the menu when it has
53
+ // tabs, switchers or global dropdowns to reach - just without the sidebar part.
54
+ const showMenu = hasMobileMenu({ sidebar: showSidebarCol, selectors, globalDropdowns });
51
55
  ---
52
- {showSidebarCol && (
56
+ {showMenu && (
53
57
  <div class="wd-mobile-menu" id="wd-mobile-menu" inert>
54
58
  <div class="wd-mobile-menu-backdrop" data-mobile-menu-backdrop></div>
55
59
  <div class="wd-mobile-menu-panel" role="dialog" aria-modal="true" aria-label="Navigation">
@@ -171,9 +175,11 @@ const { config, selectors, globalDropdowns, showSidebarCol, logoLight, logoDark,
171
175
  )}
172
176
  </div>
173
177
  )}
174
- <div class="wd-mobile-menu-sidebar">
175
- <slot name="sidebar" />
176
- </div>
178
+ {showSidebarCol && (
179
+ <div class="wd-mobile-menu-sidebar">
180
+ <slot name="sidebar" />
181
+ </div>
182
+ )}
177
183
  </div>
178
184
  </div>
179
185
  </div>
@@ -1,6 +1,7 @@
1
1
  ---
2
- import type { NavTreeNode } from "../../lib/config";
2
+ import { isExternalHref, type NavTreeNode, type Selector } from "../../lib/config";
3
3
  import NavTree from "./NavTree.astro";
4
+ import AppIcon from "../../components/AppIcon.astro";
4
5
  // Two colorways of the same "Powered by writedocs" mark, not a
5
6
  // theme-matched pair in the wd-logo-light/wd-logo-dark sense (that
6
7
  // convention names each file by which theme it's *shown in*) - these
@@ -32,11 +33,50 @@ interface Props {
32
33
  navTree: NavTreeNode[];
33
34
  hrefForSlug: (slug: string) => string;
34
35
  currentSlug: string;
36
+ // Switchers that sit at the top of the sidebar instead of the top bar -
37
+ // products inside a tab or dropdown (lib/selector-placement.js).
38
+ switchers?: Selector[];
35
39
  }
36
- const { navTree, hrefForSlug, currentSlug } = Astro.props as Props;
40
+ const { navTree, hrefForSlug, currentSlug, switchers = [] } = Astro.props as Props;
37
41
  ---
38
42
 
39
43
  <nav class="wd-sidebar">
44
+ {switchers.map((sel) => {
45
+ const current = sel.options.find((o) => o.active) ?? sel.options[0];
46
+ return (
47
+ <div class="wd-dropdown wd-sidebar-switcher" data-selector-kind={sel.kind}>
48
+ <button type="button" class="wd-dropdown-trigger wd-sidebar-switcher-trigger" aria-expanded="false">
49
+ <AppIcon icon={current.icon} class="wd-tab-icon wd-sidebar-switcher-icon" />
50
+ <span class="wd-sidebar-switcher-text">
51
+ <span class="wd-sidebar-switcher-label">{current.label}</span>
52
+ {current.description && <span class="wd-sidebar-switcher-desc">{current.description}</span>}
53
+ </span>
54
+ <svg class="wd-sidebar-switcher-chevrons" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
55
+ <path d="M3.5 4.5L6 2L8.5 4.5M3.5 7.5L6 10L8.5 7.5" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round" stroke-linejoin="round" />
56
+ </svg>
57
+ </button>
58
+ <div class="wd-dropdown-menu wd-sidebar-switcher-menu">
59
+ <div class="wd-dropdown-menu-panel">
60
+ {sel.options.map((opt) => (
61
+ <a
62
+ href={opt.href}
63
+ class={opt.active ? "active" : ""}
64
+ aria-current={opt.active ? "true" : undefined}
65
+ target={isExternalHref(opt.href) ? "_blank" : undefined}
66
+ rel={isExternalHref(opt.href) ? "noopener noreferrer" : undefined}
67
+ >
68
+ <AppIcon icon={opt.icon} class="wd-tab-icon wd-sidebar-switcher-icon" />
69
+ <span class="wd-sidebar-switcher-text">
70
+ <span class="wd-sidebar-switcher-label">{opt.label}</span>
71
+ {opt.description && <span class="wd-sidebar-switcher-desc">{opt.description}</span>}
72
+ </span>
73
+ </a>
74
+ ))}
75
+ </div>
76
+ </div>
77
+ </div>
78
+ );
79
+ })}
40
80
  <NavTree nodes={navTree} hrefForSlug={hrefForSlug} currentSlug={currentSlug} />
41
81
  {!watermarkDisabled && (
42
82
  <div class="wd-sidebar-watermark">
@@ -46,6 +86,74 @@ const { navTree, hrefForSlug, currentSlug } = Astro.props as Props;
46
86
  )}
47
87
  </nav>
48
88
  <style>
89
+ /* The product switcher at the top of the sidebar - Mintlify's place for
90
+ it. Full width, the current product (and its description) on the
91
+ button, every product in the menu. It opens on click only: the shared
92
+ hover-to-open (dropdown.css) would pop it open every time the pointer
93
+ crosses the sidebar. */
94
+ .wd-sidebar-switcher {
95
+ margin-bottom: 1rem;
96
+ }
97
+ .wd-sidebar-switcher-trigger {
98
+ width: 100%;
99
+ box-sizing: border-box;
100
+ gap: 0.6rem;
101
+ padding: 0.5rem 0.65rem;
102
+ border: 1px solid var(--wd-border);
103
+ border-radius: 0.5rem;
104
+ color: var(--wd-text);
105
+ text-align: left;
106
+ }
107
+ .wd-sidebar-switcher-trigger:hover {
108
+ background: var(--wd-surface);
109
+ }
110
+ .wd-sidebar-switcher-text {
111
+ display: flex;
112
+ flex-direction: column;
113
+ flex: 1;
114
+ min-width: 0;
115
+ }
116
+ .wd-sidebar-switcher-label {
117
+ font-size: 0.88rem;
118
+ font-weight: 600;
119
+ overflow: hidden;
120
+ text-overflow: ellipsis;
121
+ white-space: nowrap;
122
+ }
123
+ .wd-sidebar-switcher-desc {
124
+ font-size: 0.75rem;
125
+ font-weight: 400;
126
+ color: var(--wd-text-muted);
127
+ }
128
+ .wd-sidebar-switcher-trigger .wd-sidebar-switcher-desc {
129
+ overflow: hidden;
130
+ text-overflow: ellipsis;
131
+ white-space: nowrap;
132
+ }
133
+ .wd-sidebar-switcher-chevrons {
134
+ flex-shrink: 0;
135
+ color: var(--wd-text-muted);
136
+ }
137
+ .wd-sidebar-switcher:not(.open) .wd-sidebar-switcher-menu {
138
+ display: none;
139
+ }
140
+ .wd-sidebar-switcher-menu {
141
+ right: 0;
142
+ }
143
+ .wd-sidebar-switcher-menu .wd-dropdown-menu-panel {
144
+ min-width: 0;
145
+ }
146
+ .wd-sidebar-switcher-menu a {
147
+ align-items: flex-start;
148
+ }
149
+ .wd-sidebar-switcher-menu .wd-sidebar-switcher-icon {
150
+ margin-top: 0.2rem;
151
+ }
152
+ /* The mobile menu already lists every level as its own row above the
153
+ sidebar - no second copy there. */
154
+ :global(#wd-mobile-menu) .wd-sidebar-switcher {
155
+ display: none;
156
+ }
49
157
  /* Sticky + its own scrollbar, not just "moves along until it runs out
50
158
  of column to stick within" (position: sticky's default behavior,
51
159
  which is what this had before - a tall nav tree would eventually
@@ -13,6 +13,7 @@
13
13
  // comment for the fuller rationale.
14
14
  import { isExternalHref, type DocsConfig, type Selector, type GlobalDropdownView } from '../../lib/config';
15
15
  import AppIcon from '../../components/AppIcon.astro';
16
+ import { selectorPlacements, hasMobileMenu } from '../../lib/selector-placement.js';
16
17
  import '../styles/dropdown.css';
17
18
  import '../styles/topbar.css';
18
19
 
@@ -52,9 +53,12 @@ const { config, selectors, globalDropdowns, showSidebarCol, logoLight, logoDark,
52
53
  // switcher), this one is dropped from switcherSelectors entirely -
53
54
  // `selectors` is in root-to-leaf path order, so "nested directly inside a
54
55
  // tab" just means the previous entry in the array is the 'tab' selector.
55
- const switcherSelectors = selectors.filter(
56
- (sel, i) => sel.kind !== 'tab' && !(sel.kind === 'dropdown' && selectors[i - 1]?.kind === 'tab')
57
- );
56
+ // Which level goes where - lib/selector-placement.js. A product inside a tab
57
+ // or dropdown sits at the top of the sidebar instead (Sidebar.astro), when
58
+ // the page has one.
59
+ const placements = selectorPlacements(selectors, { sidebar: showSidebarCol });
60
+ const showMenuButton = hasMobileMenu({ sidebar: showSidebarCol, selectors, globalDropdowns });
61
+ const switcherSelectors = selectors.filter((_, i) => placements[i] === 'topbar');
58
62
  const tabSelectors = selectors.filter((sel) => sel.kind === 'tab');
59
63
  // One row per level of tabs: a tab holding its own tabs gets a second row
60
64
  // underneath, with that tab's tabs. The global dropdowns stay in the first
@@ -64,12 +68,13 @@ const tabRows = tabSelectors.length > 0 ? tabSelectors : globalDropdowns.length
64
68
  <header class="wd-topbar">
65
69
  <div class="wd-topbar-row wd-topbar-row-brand">
66
70
  <div class="wd-topbar-inner">
67
- {/* Only rendered on pages that would show a sidebar at all
68
- (showSidebarCol - same condition MobileMenu.astro's own
69
- rendering is gated on). Hidden entirely above 860px via CSS
70
- (.wd-mobile-menu-toggle's own media query in topbar.css), not
71
- JS - so there's no flash-of-hamburger on a desktop-width load. */}
72
- {showSidebarCol && (
71
+ {/* Only rendered on pages that have a mobile menu - a sidebar, or
72
+ navigation the top bar hides on a phone (hasMobileMenu(), the same
73
+ condition MobileMenu.astro's own rendering is gated on). Hidden
74
+ entirely above 860px via CSS (.wd-mobile-menu-toggle's own media
75
+ query in topbar.css), not JS - so there's no flash-of-hamburger on
76
+ a desktop-width load. */}
77
+ {showMenuButton && (
73
78
  <button
74
79
  type="button"
75
80
  class="wd-mobile-menu-toggle"
@@ -146,6 +146,12 @@
146
146
  padding-bottom: 0.75rem;
147
147
  border-bottom: 1px solid var(--wd-border);
148
148
  }
149
+ /* A page without a sidebar (`mode: custom`): nothing below to divide off. */
150
+ .wd-mobile-menu-selectors:last-child {
151
+ margin-bottom: 0;
152
+ padding-bottom: 0;
153
+ border-bottom: none;
154
+ }
149
155
  /* position: relative makes this the containing block for its own
150
156
  .wd-mobile-accordion-options panel below, which floats over whatever
151
157
  comes after it instead of pushing it down the page - a `<details>`'s
@@ -523,4 +523,10 @@
523
523
  .wd-mobile-menu-toggle {
524
524
  display: flex;
525
525
  }
526
+ /* The site name takes what's left of the row and ends in "..." when it
527
+ doesn't fit - otherwise a long name wraps onto a row of its own and the
528
+ top bar grows to three rows. */
529
+ .wd-topbar-brand {
530
+ flex: 1 1 0;
531
+ }
526
532
  }
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
@@ -909,6 +926,9 @@ export interface SelectorOption {
909
926
  label: string;
910
927
  icon?: string;
911
928
  tag?: string;
929
+ // A product's `description` - shown under its name where there's room
930
+ // (the sidebar's product switcher, see lib/selector-placement.js).
931
+ description?: string;
912
932
  href: string;
913
933
  active: boolean;
914
934
  // Set when this option's own container is a `dropdowns` list (e.g. a
@@ -976,6 +996,7 @@ export function buildSelectors(
976
996
  label: labelOf(item),
977
997
  icon: iconOf(item),
978
998
  tag: 'tag' in item ? item.tag : undefined,
999
+ description: 'description' in item ? item.description : undefined,
979
1000
  href: 'href' in item ? item.href : hrefForSlug(targetSlug),
980
1001
  active: isActive,
981
1002
  dropdown: dropdownMenuOf(item, hrefForSlug, isActive ? path[segIndex + 1] : undefined),
@@ -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
  }
@@ -0,0 +1,30 @@
1
+ // Where each level of a page's navigation path shows its switcher - one
2
+ // entry per Selector (buildSelectors() in lib/config.ts), root first:
3
+ //
4
+ // 'tabs' a row of tabs in the top bar (a second row for tabs inside tabs)
5
+ // 'tab-menu' the menu of the tab it sits in (dropdowns directly inside a tab)
6
+ // 'sidebar' the top of the sidebar: products inside a tab or a dropdown,
7
+ // like Mintlify's - they pick what the sidebar shows
8
+ // 'topbar' a switcher next to the site name (everything else)
9
+ //
10
+ // Without a sidebar on the page (`mode: custom` / `blank`), a 'sidebar'
11
+ // switcher goes to the top bar instead. The mobile menu lists every level
12
+ // either way.
13
+
14
+ export function selectorPlacements(selectors, { sidebar = true } = {}) {
15
+ return selectors.map((sel, i) => {
16
+ const parent = selectors[i - 1]?.kind;
17
+ if (sel.kind === 'tab') return 'tabs';
18
+ if (sel.kind === 'dropdown' && parent === 'tab') return 'tab-menu';
19
+ if (sel.kind === 'product' && (parent === 'tab' || parent === 'dropdown')) return sidebar ? 'sidebar' : 'topbar';
20
+ return 'topbar';
21
+ });
22
+ }
23
+
24
+ /** Whether a page gets the mobile menu (its button in the top bar, and the
25
+ * panel - TopBar.astro and MobileMenu.astro both ask this). On a phone the
26
+ * top bar hides the tabs and switchers, so any page with navigation to
27
+ * reach needs it - with a sidebar or without (`mode: custom`). */
28
+ export function hasMobileMenu({ sidebar, selectors = [], globalDropdowns = [] }) {
29
+ return sidebar || selectors.length > 0 || globalDropdowns.length > 0;
30
+ }
@@ -70,6 +70,7 @@ import { Tree, FileTree, Color, GitHub } from "../components/compound";
70
70
  import ApiPlayground from "../components/ApiPlayground.astro";
71
71
  import ApiReferencePanel from "../components/ApiReferencePanel.astro";
72
72
  import { searchScope, sectionTrail, ALL_SCOPES } from "../lib/search-scope.js";
73
+ import { selectorPlacements } from "../lib/selector-placement.js";
73
74
 
74
75
  // A page is hand-written (the `pages` collection, sourced from anywhere
75
76
  // in the project - docs/ has no special status, see findAllPages() in
@@ -157,7 +158,7 @@ export async function getStaticPaths() {
157
158
  // rootAlreadyClaimed rules out), so the ternary below always resolves
158
159
  // to the non-root branch.
159
160
  const rootRedirect = firstEntry
160
- ? [{ params: { slug: undefined }, props: { redirectTo: `/${normalizeEntryId(firstEntry.id)}/` } }]
161
+ ? [{ params: { slug: undefined }, props: { redirectTo: `/${normalizeEntryId(firstEntry.id)}/`, temporary: true } }]
161
162
  : [];
162
163
 
163
164
  // Tracks every file id a nav-driven route below already claims, so
@@ -258,6 +259,8 @@ export async function getStaticPaths() {
258
259
 
259
260
  interface Props {
260
261
  redirectTo?: string;
262
+ // The automatic "/" redirect: temporary, see below.
263
+ temporary?: boolean;
261
264
  entry?: DocsEntry;
262
265
  prev?: { slug: string; group: string | null } | null;
263
266
  next?: { slug: string; group: string | null } | null;
@@ -273,11 +276,15 @@ interface Props {
273
276
 
274
277
  const rawProps = Astro.props as Props;
275
278
  if (rawProps.redirectTo) {
276
- // 301, not Astro's default 302: in a static build the status only picks
277
- // the meta refresh delay (astro/dist/core/routing/3xx.js) - 2 seconds of
278
- // "Redirecting from..." text for a 302, none for a 301. Hosts that read
279
- // _redirects get a real 301 for "/" too (write-redirects-file.js).
280
- 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);
281
288
  }
282
289
  const {
283
290
  entry,
@@ -413,6 +420,10 @@ const currentPath = hrefForSlug(currentFileId);
413
420
  // it set one explicitly, still wins.
414
421
  const pageMode = isHidden && entry.data.mode === "default" ? "frame" : entry.data.mode;
415
422
  const showSidebar = pageMode === "default" || pageMode === "wide";
423
+ // Products inside a tab or dropdown switch at the top of the sidebar, not in
424
+ // the top bar (lib/selector-placement.js - TopBar.astro applies the same rule).
425
+ const sidebarPlacements = selectorPlacements(selectors, { sidebar: showSidebar });
426
+ const sidebarSwitchers = selectors.filter((_, i) => sidebarPlacements[i] === "sidebar");
416
427
  const showToc = pageMode === "default";
417
428
  // 'custom' and 'blank' both get the bare wd-canvas treatment below (no
418
429
  // auto <h1>, no prev/next, no prose width/padding) - they only differ in
@@ -493,7 +504,7 @@ const components = {
493
504
  mode={pageMode}
494
505
  lang={pageLang}
495
506
  >
496
- {showSidebar && <Sidebar slot="sidebar" navTree={navTree} hrefForSlug={hrefForSlug} currentSlug={currentFileId} />}
507
+ {showSidebar && <Sidebar slot="sidebar" navTree={navTree} hrefForSlug={hrefForSlug} currentSlug={currentFileId} switchers={sidebarSwitchers} />}
497
508
  {
498
509
  isCanvasMode ? (
499
510
  // 'custom' and 'blank' both get this treatment: no auto <h1>, no
@@ -923,13 +934,25 @@ const components = {
923
934
  direct .wd-article child. */
924
935
  .wd-article-header {
925
936
  display: flex;
937
+ flex-wrap: wrap;
926
938
  align-items: center;
927
939
  justify-content: space-between;
928
- gap: 1rem;
940
+ gap: 0.75rem 1rem;
929
941
  margin: 0 0 1rem;
930
942
  }
931
943
  .wd-article-header h1 {
932
944
  margin: 0;
945
+ /* A word longer than the whole line (a long product name on a phone)
946
+ breaks rather than pushing the page wider than the screen. */
947
+ overflow-wrap: break-word;
948
+ }
949
+ /* The title takes the room the menu leaves. Its basis is 0, so the row
950
+ only wraps when its longest word and the menu can't share a line - on
951
+ a desktop, a long title still wraps beside the menu; on a phone, a long
952
+ word sends the menu below the title instead of off the screen. */
953
+ .wd-article-header > h1,
954
+ .wd-article-header > .wd-article-title {
955
+ flex: 1 1 0;
933
956
  }
934
957
  /* Frontmatter `deprecated: true` (Mintlify's) - same amber as the
935
958
  sidebar's own deprecated tag (NavTree.astro) and Parameter's