@writedocs/generator 0.9.0 → 0.9.1

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.1",
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,84 @@
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
+ const REFRESH = /<meta http-equiv="refresh" content="0;url=([^"]*)"\s*\/?>/i;
18
+ const ASTRO_REDIRECT = /<title>Redirecting to:/;
19
+ const CANONICAL = /<link rel="canonical" href="([^"]*)"\s*\/?>/i;
20
+
21
+ const DEFAULT_LIGHT = '#ffffff';
22
+ const DEFAULT_DARK = '#0b1120';
23
+
24
+ function decodeAttr(value) {
25
+ return value.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
26
+ }
27
+ function encodeAttr(value) {
28
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
29
+ }
30
+ /** A color from writedocs.json, only if it's safe inside a <style>. */
31
+ function cssColor(value, fallback) {
32
+ return typeof value === 'string' && /^[#a-zA-Z0-9(),.%\s-]{1,64}$/.test(value) ? value : fallback;
33
+ }
34
+
35
+ /** The page for a redirect to `target`. */
36
+ export function redirectPageHtml({ target, canonical = null, background = {} }) {
37
+ const light = cssColor(background.light, DEFAULT_LIGHT);
38
+ const dark = cssColor(background.dark, DEFAULT_DARK);
39
+ // Inside <script>: a JSON string, with "<" escaped so a target can't end the script.
40
+ const js = JSON.stringify(target).replace(/</g, '\\u003c');
41
+ const attr = encodeAttr(target);
42
+ return [
43
+ '<!doctype html>',
44
+ '<html lang="en">',
45
+ '<head>',
46
+ '<meta charset="utf-8">',
47
+ `<script>location.replace(${js} + location.search + location.hash)</script>`,
48
+ `<meta http-equiv="refresh" content="0;url=${attr}">`,
49
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
50
+ '<meta name="robots" content="noindex">',
51
+ canonical ? `<link rel="canonical" href="${encodeAttr(canonical)}">` : '',
52
+ '<title>Redirecting…</title>',
53
+ // Same theme choice as every page (BaseLayout.astro): the stored one, else the system's.
54
+ "<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>",
55
+ `<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>`,
56
+ '</head>',
57
+ `<body><a href="${attr}">Continue to ${attr}</a></body>`,
58
+ '</html>',
59
+ '',
60
+ ].filter(Boolean).join('\n');
61
+ }
62
+
63
+ /** Rewrites every Astro redirect page under `distDir`. Returns how many. */
64
+ export function rewriteRedirectPages(distDir, { background } = {}) {
65
+ let count = 0;
66
+ const walk = (dir) => {
67
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
68
+ const full = path.join(dir, entry.name);
69
+ if (entry.isDirectory()) {
70
+ if (entry.name !== '_astro' && entry.name !== 'pagefind') walk(full);
71
+ continue;
72
+ }
73
+ if (!entry.name.endsWith('.html')) continue;
74
+ const html = fs.readFileSync(full, 'utf8');
75
+ const refresh = REFRESH.exec(html);
76
+ if (!refresh || !ASTRO_REDIRECT.test(html)) continue;
77
+ const canonical = CANONICAL.exec(html);
78
+ fs.writeFileSync(full, redirectPageHtml({ target: decodeAttr(refresh[1]), canonical: canonical ? decodeAttr(canonical[1]) : null, background }));
79
+ count++;
80
+ }
81
+ };
82
+ walk(distDir);
83
+ return count;
84
+ }
@@ -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
@@ -909,6 +909,9 @@ export interface SelectorOption {
909
909
  label: string;
910
910
  icon?: string;
911
911
  tag?: string;
912
+ // A product's `description` - shown under its name where there's room
913
+ // (the sidebar's product switcher, see lib/selector-placement.js).
914
+ description?: string;
912
915
  href: string;
913
916
  active: boolean;
914
917
  // Set when this option's own container is a `dropdowns` list (e.g. a
@@ -976,6 +979,7 @@ export function buildSelectors(
976
979
  label: labelOf(item),
977
980
  icon: iconOf(item),
978
981
  tag: 'tag' in item ? item.tag : undefined,
982
+ description: 'description' in item ? item.description : undefined,
979
983
  href: 'href' in item ? item.href : hrefForSlug(targetSlug),
980
984
  active: isActive,
981
985
  dropdown: dropdownMenuOf(item, hrefForSlug, isActive ? path[segIndex + 1] : undefined),
@@ -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
@@ -413,6 +414,10 @@ const currentPath = hrefForSlug(currentFileId);
413
414
  // it set one explicitly, still wins.
414
415
  const pageMode = isHidden && entry.data.mode === "default" ? "frame" : entry.data.mode;
415
416
  const showSidebar = pageMode === "default" || pageMode === "wide";
417
+ // Products inside a tab or dropdown switch at the top of the sidebar, not in
418
+ // the top bar (lib/selector-placement.js - TopBar.astro applies the same rule).
419
+ const sidebarPlacements = selectorPlacements(selectors, { sidebar: showSidebar });
420
+ const sidebarSwitchers = selectors.filter((_, i) => sidebarPlacements[i] === "sidebar");
416
421
  const showToc = pageMode === "default";
417
422
  // 'custom' and 'blank' both get the bare wd-canvas treatment below (no
418
423
  // auto <h1>, no prev/next, no prose width/padding) - they only differ in
@@ -493,7 +498,7 @@ const components = {
493
498
  mode={pageMode}
494
499
  lang={pageLang}
495
500
  >
496
- {showSidebar && <Sidebar slot="sidebar" navTree={navTree} hrefForSlug={hrefForSlug} currentSlug={currentFileId} />}
501
+ {showSidebar && <Sidebar slot="sidebar" navTree={navTree} hrefForSlug={hrefForSlug} currentSlug={currentFileId} switchers={sidebarSwitchers} />}
497
502
  {
498
503
  isCanvasMode ? (
499
504
  // 'custom' and 'blank' both get this treatment: no auto <h1>, no
@@ -923,13 +928,25 @@ const components = {
923
928
  direct .wd-article child. */
924
929
  .wd-article-header {
925
930
  display: flex;
931
+ flex-wrap: wrap;
926
932
  align-items: center;
927
933
  justify-content: space-between;
928
- gap: 1rem;
934
+ gap: 0.75rem 1rem;
929
935
  margin: 0 0 1rem;
930
936
  }
931
937
  .wd-article-header h1 {
932
938
  margin: 0;
939
+ /* A word longer than the whole line (a long product name on a phone)
940
+ breaks rather than pushing the page wider than the screen. */
941
+ overflow-wrap: break-word;
942
+ }
943
+ /* The title takes the room the menu leaves. Its basis is 0, so the row
944
+ only wraps when its longest word and the menu can't share a line - on
945
+ a desktop, a long title still wraps beside the menu; on a phone, a long
946
+ word sends the menu below the title instead of off the screen. */
947
+ .wd-article-header > h1,
948
+ .wd-article-header > .wd-article-title {
949
+ flex: 1 1 0;
933
950
  }
934
951
  /* Frontmatter `deprecated: true` (Mintlify's) - same amber as the
935
952
  sidebar's own deprecated tag (NavTree.astro) and Parameter's