@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 +1 -1
- package/src/cli/build.js +6 -0
- package/src/cli/rewrite-redirect-pages.js +84 -0
- package/src/components/CopyPageMenu.astro +3 -0
- package/src/layout/components/MobileMenu.astro +10 -4
- package/src/layout/components/Sidebar.astro +110 -2
- package/src/layout/components/TopBar.astro +14 -9
- package/src/layout/styles/mobile-menu.css +6 -0
- package/src/layout/styles/topbar.css +6 -0
- package/src/lib/config.ts +4 -0
- package/src/lib/selector-placement.js +30 -0
- package/src/pages/[...slug].astro +19 -2
package/package.json
CHANGED
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(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
26
|
+
}
|
|
27
|
+
function encodeAttr(value) {
|
|
28
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
{
|
|
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
|
-
|
|
175
|
-
<
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
rendering is gated on). Hidden
|
|
70
|
-
(.wd-mobile-menu-toggle's own media
|
|
71
|
-
JS - so there's no flash-of-hamburger on
|
|
72
|
-
|
|
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
|