@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 +1 -1
- package/src/cli/build.js +6 -0
- package/src/cli/rewrite-redirect-pages.js +85 -0
- package/src/cli/write-redirects-file.js +3 -1
- 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 +23 -2
- package/src/lib/content-check.js +4 -6
- package/src/lib/pages.js +19 -4
- package/src/lib/selector-placement.js +30 -0
- package/src/pages/[...slug].astro +31 -8
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,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(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
27
|
+
}
|
|
28
|
+
function encodeAttr(value) {
|
|
29
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
@@ -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),
|
package/src/lib/content-check.js
CHANGED
|
@@ -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
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
277
|
-
//
|
|
278
|
-
// "
|
|
279
|
-
//
|
|
280
|
-
|
|
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
|