@writedocs/generator 0.8.1 → 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/astro.config.mjs +2 -1
- package/bin/writedocs.js +10 -7
- package/package.json +1 -1
- package/src/cli/build.js +21 -0
- package/src/cli/check-built-styles.js +33 -0
- package/src/cli/dev.js +4 -0
- package/src/cli/rewrite-redirect-pages.js +84 -0
- package/src/cli/run-astro.js +4 -0
- package/src/components/ApiLangSelect.astro +1 -2
- package/src/components/CopyPageMenu.astro +122 -34
- package/src/layout/BaseLayout.astro +2 -0
- package/src/layout/components/MobileMenu.astro +10 -4
- package/src/layout/components/Sidebar.astro +110 -2
- package/src/layout/components/TopBar.astro +127 -103
- package/src/layout/styles/mobile-menu.css +6 -0
- package/src/layout/styles/search-modal.css +27 -0
- package/src/layout/styles/topbar.css +113 -1
- package/src/lib/canonical-path.js +11 -0
- package/src/lib/config-schema.js +99 -6
- package/src/lib/config-schema.ts +120 -7
- package/src/lib/config.ts +4 -0
- package/src/lib/json-schema-descriptions.js +1 -1
- package/src/lib/mcp-links.js +30 -0
- package/src/lib/mintlify-convert.js +5 -5
- package/src/lib/search-scope.js +44 -0
- package/src/lib/selector-placement.js +30 -0
- package/src/lib/tabs-strip-offset.js +31 -0
- package/src/lib/writedocs-temp-dir.js +3 -1
- package/src/pages/[...slug].astro +72 -5
- package/src/scripts/dropdowns.ts +70 -35
- package/src/scripts/search.ts +58 -4
- package/src/scripts/tabs-strip.ts +169 -0
- package/writedocs.schema.json +6 -3
package/astro.config.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import { remarkExtractInlineReactComponents } from './src/lib/mdx-inline-react.j
|
|
|
37
37
|
import { writedocsTempDir, writedocsBuildStagingDir } from './src/lib/writedocs-temp-dir.js';
|
|
38
38
|
import { stylesAssetFallback } from './src/lib/styles-asset-integration.js';
|
|
39
39
|
import { mcpDevServer } from './src/lib/mcp-dev-integration.js';
|
|
40
|
+
import { canonicalPath } from './src/lib/canonical-path.js';
|
|
40
41
|
import { report } from './src/lib/cli-report.js';
|
|
41
42
|
import {
|
|
42
43
|
loadDocsConfig,
|
|
@@ -54,7 +55,7 @@ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
|
|
|
54
55
|
// its dependencies' own module resolution keeps working), while build.js
|
|
55
56
|
// does the actual cross-filesystem-safe copy into contentDir/dist as an
|
|
56
57
|
// explicit final step once the build itself is done.
|
|
57
|
-
const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT || path.dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
const packageRoot = canonicalPath(process.env.WRITEDOCS_PACKAGE_ROOT || path.dirname(fileURLToPath(import.meta.url)));
|
|
58
59
|
// Where this package's dependencies are: the node_modules folder that
|
|
59
60
|
// contains an installed package (<project>/node_modules/@writedocs/
|
|
60
61
|
// generator -> <project>/node_modules), or packageRoot itself in a checkout,
|
package/bin/writedocs.js
CHANGED
|
@@ -11,6 +11,7 @@ import { requireBuildKey } from '../src/cli/build-auth.js';
|
|
|
11
11
|
import { log, step, plural, color, CliExit, errorText, stopActiveStep } from '../src/cli/output.js';
|
|
12
12
|
import { startUpdateCheck, showUpdateNotice } from '../src/cli/update-check.js';
|
|
13
13
|
import { readConfigText } from '../src/lib/config-file.js';
|
|
14
|
+
import { canonicalPath } from '../src/lib/canonical-path.js';
|
|
14
15
|
// O MESMO modulo que o build usa (via loadDocsConfig, que reexporta daqui) e
|
|
15
16
|
// que a plataforma importa por `@writedocs/generator/config-schema` - e o que
|
|
16
17
|
// faz os tres reportarem os mesmos problemas com as mesmas palavras, em vez de
|
|
@@ -32,7 +33,9 @@ import {
|
|
|
32
33
|
} from '../src/lib/config-schema.js';
|
|
33
34
|
|
|
34
35
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
-
|
|
36
|
+
// The drive letter in capitals, however the shell spelled it - see
|
|
37
|
+
// src/lib/canonical-path.js.
|
|
38
|
+
const packageRoot = canonicalPath(path.resolve(__dirname, '..'));
|
|
36
39
|
// Read rather than hardcode - a literal version string here silently drifts
|
|
37
40
|
// from package.json's own "version" the moment either one is bumped without
|
|
38
41
|
// the other (exactly what `npm version <bump>` does: it only touches
|
|
@@ -64,7 +67,7 @@ program
|
|
|
64
67
|
.option('--no-open', "don't open the preview in the browser")
|
|
65
68
|
.action(async (dir, opts) => {
|
|
66
69
|
await runDev({
|
|
67
|
-
contentDir: path.resolve(process.cwd(), dir),
|
|
70
|
+
contentDir: canonicalPath(path.resolve(process.cwd(), dir)),
|
|
68
71
|
packageRoot,
|
|
69
72
|
port: opts.port,
|
|
70
73
|
verbose: Boolean(opts.verbose),
|
|
@@ -86,7 +89,7 @@ program
|
|
|
86
89
|
.option('-k, --key <key>', 'build authorization key (or set WRITEDOCS_API_KEY)')
|
|
87
90
|
.option('--verbose', "also print Astro's and Vite's own output")
|
|
88
91
|
.action(async (dir, opts) => {
|
|
89
|
-
const contentDir = path.resolve(process.cwd(), dir);
|
|
92
|
+
const contentDir = canonicalPath(path.resolve(process.cwd(), dir));
|
|
90
93
|
// Project-scoped, not global: a .env sitting next to this project's own
|
|
91
94
|
// writedocs.json (WRITEDOCS_API_KEY) is picked up automatically, so
|
|
92
95
|
// build doesn't need it exported by hand every
|
|
@@ -106,7 +109,7 @@ program
|
|
|
106
109
|
.argument('[dir]', 'content directory (contains writedocs.json)', '.')
|
|
107
110
|
.option('--config-only', 'check writedocs.json only, not the pages')
|
|
108
111
|
.action(async (dir, options) => {
|
|
109
|
-
const contentDir = path.resolve(process.cwd(), dir);
|
|
112
|
+
const contentDir = canonicalPath(path.resolve(process.cwd(), dir));
|
|
110
113
|
const configPath = path.join(contentDir, 'writedocs.json');
|
|
111
114
|
if (!fs.existsSync(configPath)) {
|
|
112
115
|
// Mesma mensagem que loadDocsConfig() ja da pro mesmo caso.
|
|
@@ -192,7 +195,7 @@ program
|
|
|
192
195
|
.description('Check every internal link - pages, anchors and files - in the pages and writedocs.json')
|
|
193
196
|
.argument('[dir]', 'content directory (contains writedocs.json)', '.')
|
|
194
197
|
.action(async (dir) => {
|
|
195
|
-
const contentDir = path.resolve(process.cwd(), dir);
|
|
198
|
+
const contentDir = canonicalPath(path.resolve(process.cwd(), dir));
|
|
196
199
|
const { preflightCheck } = await import('../src/cli/preflight.js');
|
|
197
200
|
preflightCheck(contentDir);
|
|
198
201
|
const configText = readConfigText(contentDir);
|
|
@@ -227,7 +230,7 @@ program
|
|
|
227
230
|
.description('Check accessibility - color contrast, image alt text, headings, link text')
|
|
228
231
|
.argument('[dir]', 'content directory (contains writedocs.json)', '.')
|
|
229
232
|
.action(async (dir) => {
|
|
230
|
-
const contentDir = path.resolve(process.cwd(), dir);
|
|
233
|
+
const contentDir = canonicalPath(path.resolve(process.cwd(), dir));
|
|
231
234
|
const { preflightCheck } = await import('../src/cli/preflight.js');
|
|
232
235
|
preflightCheck(contentDir);
|
|
233
236
|
const configText = readConfigText(contentDir);
|
|
@@ -276,7 +279,7 @@ program
|
|
|
276
279
|
const { runConvert } = await import('../src/cli/convert.js');
|
|
277
280
|
await runConvert({
|
|
278
281
|
source: legacy ? 'writedocs' : 'mintlify',
|
|
279
|
-
contentDir: path.resolve(process.cwd(), dir),
|
|
282
|
+
contentDir: canonicalPath(path.resolve(process.cwd(), dir)),
|
|
280
283
|
force: Boolean(options.force),
|
|
281
284
|
dryRun: Boolean(options.dryRun),
|
|
282
285
|
});
|
package/package.json
CHANGED
package/src/cli/build.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { canonicalPath } from '../lib/canonical-path.js';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { runAstro } from './run-astro.js';
|
|
@@ -5,13 +6,19 @@ import { runPagefind } from './run-pagefind.js';
|
|
|
5
6
|
import { preflightCheck, sameDriveCheck, writableInstallCheck } from './preflight.js';
|
|
6
7
|
import { generateApiPages } from './generate-api-pages.js';
|
|
7
8
|
import { writeRedirectsFile } from './write-redirects-file.js';
|
|
9
|
+
import { rewriteRedirectPages } from './rewrite-redirect-pages.js';
|
|
10
|
+
import { readConfigText } from '../lib/config-file.js';
|
|
8
11
|
import { writeMcpFiles } from './write-mcp-files.js';
|
|
12
|
+
import { pagesWithoutStyles } from './check-built-styles.js';
|
|
9
13
|
import { writedocsBuildStagingDir } from '../lib/writedocs-temp-dir.js';
|
|
10
14
|
import { log, step, plural, duration, displayPath, formatProblems, color, CliExit } from './output.js';
|
|
11
15
|
import { describeError, builtRoute, authorWarning, verboseLine } from './astro-output.js';
|
|
12
16
|
import { reportApiPages } from './api-pages-output.js';
|
|
13
17
|
|
|
14
18
|
export async function runBuild({ contentDir, packageRoot, verbose = false }) {
|
|
19
|
+
// One spelling of each folder throughout - see lib/canonical-path.js.
|
|
20
|
+
contentDir = canonicalPath(contentDir);
|
|
21
|
+
packageRoot = canonicalPath(packageRoot);
|
|
15
22
|
const started = Date.now();
|
|
16
23
|
preflightCheck(contentDir);
|
|
17
24
|
writableInstallCheck(packageRoot);
|
|
@@ -108,11 +115,25 @@ export async function runBuild({ contentDir, packageRoot, verbose = false }) {
|
|
|
108
115
|
fs.rmSync(distDir, { recursive: true, force: true });
|
|
109
116
|
fs.cpSync(stagingDir, distDir, { recursive: true });
|
|
110
117
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
118
|
+
// Every page writedocs renders links the site's stylesheet. Builds have
|
|
119
|
+
// come out without it on every page while reporting success (the package
|
|
120
|
+
// folder spelled `c:`, fixed in lib/canonical-path.js) - so it's checked
|
|
121
|
+
// here, and a build like that stops instead of being deployed unstyled.
|
|
122
|
+
const unstyled = pagesWithoutStyles(distDir);
|
|
123
|
+
if (unstyled.length) {
|
|
124
|
+
log.error(`The build produced ${plural(unstyled.length, 'page')} without the site's styles - for example ${unstyled[0]}.`);
|
|
125
|
+
log.detail(color.dim('This is a fault in the build, not in your pages. Build again; if it happens again, report it with the output of --verbose.'));
|
|
126
|
+
throw new CliExit(1);
|
|
127
|
+
}
|
|
111
128
|
// Astro's own writedocs.json `redirects` + the automatic "/" redirect only
|
|
112
129
|
// ever produce client-side meta-refresh pages (see write-redirects-file.js)
|
|
113
130
|
// - this turns those into real instant edge redirects on hosts that read
|
|
114
131
|
// a `_redirects` file (Cloudflare Pages, Netlify), purely additively.
|
|
115
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 });
|
|
116
137
|
// The site's MCP server at /mcp: mcp-index.json is already in dist/ (an
|
|
117
138
|
// Astro route); this adds the Cloudflare _worker.js that serves it - see
|
|
118
139
|
// write-mcp-files.js for every host.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// After a build: the pages writedocs rendered that came out without the
|
|
2
|
+
// site's styles. Every page BaseLayout.astro renders carries
|
|
3
|
+
// <meta name="generator" content="writedocs"> and links its stylesheet
|
|
4
|
+
// (/_astro/*.css), or inlines it in a <style>. Other HTML in dist/ - a
|
|
5
|
+
// redirect page, a file from public/ - isn't writedocs' to check.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const MARKER = /<meta name="generator" content="writedocs"/;
|
|
10
|
+
const STYLES = /<link[^>]+rel="stylesheet"[^>]+href="\/_astro\/[^"]+\.css"|<link[^>]+href="\/_astro\/[^"]+\.css"[^>]+rel="stylesheet"|<style[\s>]/;
|
|
11
|
+
|
|
12
|
+
function htmlFiles(dir) {
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
15
|
+
const full = path.join(dir, entry.name);
|
|
16
|
+
if (entry.isDirectory()) {
|
|
17
|
+
if (entry.name !== '_astro' && entry.name !== 'pagefind') out.push(...htmlFiles(full));
|
|
18
|
+
} else if (entry.name.endsWith('.html')) out.push(full);
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Paths (relative to `distDir`, with forward slashes) of writedocs pages
|
|
24
|
+
* with no stylesheet. */
|
|
25
|
+
export function pagesWithoutStyles(distDir) {
|
|
26
|
+
return htmlFiles(distDir)
|
|
27
|
+
.filter((file) => {
|
|
28
|
+
const html = fs.readFileSync(file, 'utf8');
|
|
29
|
+
return MARKER.test(html) && !STYLES.test(html);
|
|
30
|
+
})
|
|
31
|
+
.map((file) => path.relative(distDir, file).split(path.sep).join('/'))
|
|
32
|
+
.sort();
|
|
33
|
+
}
|
package/src/cli/dev.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { canonicalPath } from '../lib/canonical-path.js';
|
|
1
2
|
import { runAstro } from './run-astro.js';
|
|
2
3
|
import { preflightCheck, sameDriveCheck, writableInstallCheck } from './preflight.js';
|
|
3
4
|
import { generateApiPages } from './generate-api-pages.js';
|
|
@@ -14,6 +15,9 @@ import { shouldOpenBrowser, openBrowser } from './open-browser.js';
|
|
|
14
15
|
const REPEAT_WINDOW_MS = 2000;
|
|
15
16
|
|
|
16
17
|
export async function runDev({ contentDir, packageRoot, port, verbose = false, open = true }) {
|
|
18
|
+
// One spelling of each folder throughout - see lib/canonical-path.js.
|
|
19
|
+
contentDir = canonicalPath(contentDir);
|
|
20
|
+
packageRoot = canonicalPath(packageRoot);
|
|
17
21
|
const started = Date.now();
|
|
18
22
|
preflightCheck(contentDir);
|
|
19
23
|
writableInstallCheck(packageRoot);
|
|
@@ -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
|
+
}
|
package/src/cli/run-astro.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
import { canonicalPath } from '../lib/canonical-path.js';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
|
|
5
6
|
const workerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'astro-worker.js');
|
|
@@ -21,6 +22,9 @@ const workerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'astr
|
|
|
21
22
|
* Returns { child, exited } - `exited` resolves with the exit code.
|
|
22
23
|
*/
|
|
23
24
|
export function runAstro(command, { packageRoot, contentDir, port, onEvent }) {
|
|
25
|
+
// One spelling of each folder for Astro and Vite - see canonical-path.js.
|
|
26
|
+
packageRoot = canonicalPath(packageRoot);
|
|
27
|
+
contentDir = canonicalPath(contentDir);
|
|
24
28
|
const child = spawn(process.execPath, [workerPath, command], {
|
|
25
29
|
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
26
30
|
// Explicit, not inherited: astro.config.mjs's own `outDir` lives
|
|
@@ -42,7 +42,6 @@ const first = options[0];
|
|
|
42
42
|
type="button"
|
|
43
43
|
class="wd-dropdown-trigger wd-api-lang-trigger"
|
|
44
44
|
data-role={`${rolePrefix}-trigger`}
|
|
45
|
-
aria-haspopup="true"
|
|
46
45
|
aria-expanded="false"
|
|
47
46
|
aria-label={ariaLabel}
|
|
48
47
|
>
|
|
@@ -52,7 +51,7 @@ const first = options[0];
|
|
|
52
51
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
|
53
52
|
</svg>
|
|
54
53
|
</button>
|
|
55
|
-
<div class="wd-dropdown-menu wd-api-lang-menu"
|
|
54
|
+
<div class="wd-dropdown-menu wd-api-lang-menu">
|
|
56
55
|
<div class="wd-dropdown-menu-panel wd-api-lang-menu-panel">
|
|
57
56
|
{options.map((o, i) => (
|
|
58
57
|
<button
|
|
@@ -23,18 +23,26 @@
|
|
|
23
23
|
// (which need an absolute URL - from writedocs.json's `domain` when it's
|
|
24
24
|
// set, else from the page's own address in the browser; see
|
|
25
25
|
// enabledAssistants below).
|
|
26
|
-
// Without `copy`, the
|
|
27
|
-
//
|
|
26
|
+
// Without `copy`, the options get a labeled "Page options" trigger of their
|
|
27
|
+
// own - or, when there's just one, that option is the button itself ("Open
|
|
28
|
+
// in Claude"). With only `copy`, the primary button stands alone.
|
|
29
|
+
//
|
|
30
|
+
// The last three options connect AI tools to the site's MCP server (/mcp -
|
|
31
|
+
// see docs/dev/docs/mcp.mdx): copy its address, or add it to Cursor or VS
|
|
32
|
+
// Code through their install links (lib/mcp-links.js). [...slug].astro
|
|
33
|
+
// leaves them out when writedocs.json turns `mcp` off.
|
|
28
34
|
import { Icon } from 'astro-icon/components';
|
|
29
35
|
import type { ContextMenuOption } from '../lib/config';
|
|
36
|
+
import { mcpServerUrl, cursorInstallLink, vscodeInstallLink } from '../lib/mcp-links.js';
|
|
30
37
|
|
|
31
38
|
interface Props {
|
|
32
39
|
currentPath: string; // e.g. "/" or "/guides/foo/" - see hrefForSlug() in [...slug].astro
|
|
33
40
|
siteUrl: string | null;
|
|
41
|
+
siteName: string;
|
|
34
42
|
options: ContextMenuOption[];
|
|
35
43
|
}
|
|
36
44
|
|
|
37
|
-
const { currentPath, siteUrl, options } = Astro.props as Props;
|
|
45
|
+
const { currentPath, siteUrl, siteName, options } = Astro.props as Props;
|
|
38
46
|
|
|
39
47
|
// The [...slug].md.ts route this page is also served at - same
|
|
40
48
|
// normalizeEntryId()-driven convention that file uses to build its own
|
|
@@ -42,6 +50,7 @@ const { currentPath, siteUrl, options } = Astro.props as Props;
|
|
|
42
50
|
// slug with a literal ".md" appended instead of the trailing slash).
|
|
43
51
|
const mdPath = currentPath === '/' ? '/index.md' : `${currentPath.replace(/\/$/, '')}.md`;
|
|
44
52
|
const mdAbsoluteUrl = siteUrl ? `${siteUrl}${mdPath}` : null;
|
|
53
|
+
const mcpUrl = siteUrl ? mcpServerUrl(siteUrl) : null;
|
|
45
54
|
|
|
46
55
|
// Well-known query-string conventions for starting a new conversation
|
|
47
56
|
// pre-seeded with a prompt (not an official API for any of the three -
|
|
@@ -60,29 +69,85 @@ const assistants = [
|
|
|
60
69
|
{ id: 'claude' as const, label: 'Claude', icon: 'simple-icons:claude', base: 'https://claude.ai/new?q=', home: 'https://claude.ai/new' },
|
|
61
70
|
{ id: 'perplexity' as const, label: 'Perplexity', icon: 'simple-icons:perplexity', base: 'https://www.perplexity.ai/search?q=', home: 'https://www.perplexity.ai/' },
|
|
62
71
|
];
|
|
72
|
+
const editors = [
|
|
73
|
+
{ id: 'cursor' as const, label: 'Cursor', icon: 'simple-icons:cursor', link: cursorInstallLink, home: 'https://docs.cursor.com/context/mcp' },
|
|
74
|
+
{ id: 'vscode' as const, label: 'VS Code', icon: 'simple-icons:visualstudiocode', link: vscodeInstallLink, home: 'https://code.visualstudio.com/docs/copilot/chat/mcp-servers' },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// Every link needs an absolute URL - the page's .md for an assistant, the
|
|
78
|
+
// MCP server for an editor. With a `domain` it's known at build time and the
|
|
79
|
+
// link is complete in the HTML. Without one, the page script ([...slug].astro)
|
|
80
|
+
// completes it from the address the page is actually served from, using the
|
|
81
|
+
// data-* attributes below; until then the link goes to the tool's own page.
|
|
82
|
+
type Item = {
|
|
83
|
+
id: ContextMenuOption;
|
|
84
|
+
title: string;
|
|
85
|
+
desc: string;
|
|
86
|
+
icon: string;
|
|
87
|
+
group: 'page' | 'mcp';
|
|
88
|
+
href?: string;
|
|
89
|
+
newTab?: boolean;
|
|
90
|
+
attrs?: Record<string, string | undefined>;
|
|
91
|
+
};
|
|
92
|
+
const items: Item[] = [];
|
|
93
|
+
if (options.includes('view')) {
|
|
94
|
+
items.push({ id: 'view', title: 'View as Markdown', desc: 'View this page as plain text', icon: 'lucide:external-link', group: 'page', href: mdPath, newTab: true });
|
|
95
|
+
}
|
|
96
|
+
for (const a of assistants.filter((a) => options.includes(a.id))) {
|
|
97
|
+
items.push({
|
|
98
|
+
id: a.id,
|
|
99
|
+
title: `Open in ${a.label}`,
|
|
100
|
+
desc: 'Ask questions about this page',
|
|
101
|
+
icon: a.icon,
|
|
102
|
+
group: 'page',
|
|
103
|
+
href: mdAbsoluteUrl ? a.base + encodeURIComponent(promptFor(mdAbsoluteUrl)) : a.home,
|
|
104
|
+
newTab: true,
|
|
105
|
+
attrs: mdAbsoluteUrl ? undefined : { 'data-ask-base': a.base, 'data-md-path': mdPath },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (options.includes('mcp')) {
|
|
109
|
+
items.push({ id: 'mcp', title: 'Copy MCP server URL', desc: 'Connect AI tools to these docs', icon: 'simple-icons:modelcontextprotocol', group: 'mcp', attrs: { 'data-copy-mcp': mcpUrl ?? '' } });
|
|
110
|
+
}
|
|
111
|
+
for (const e of editors.filter((e) => options.includes(e.id))) {
|
|
112
|
+
items.push({
|
|
113
|
+
id: e.id,
|
|
114
|
+
title: `Connect to ${e.label}`,
|
|
115
|
+
desc: `Use these docs in ${e.label}`,
|
|
116
|
+
icon: e.icon,
|
|
117
|
+
group: 'mcp',
|
|
118
|
+
// An editor's own link scheme (cursor://, vscode:) - opens the app, not a tab.
|
|
119
|
+
href: mcpUrl ? e.link(siteName, mcpUrl) : e.home,
|
|
120
|
+
attrs: mcpUrl ? undefined : { 'data-mcp-install': e.id, 'data-mcp-name': siteName },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
63
123
|
|
|
64
|
-
// The assistant needs the page's absolute URL to fetch it. With a `domain`
|
|
65
|
-
// it's known at build time and the link is complete in the HTML. Without
|
|
66
|
-
// one, the page script (initCopyPageMenu() in [...slug].astro) completes it
|
|
67
|
-
// from the address the page is actually served from - `data-ask-base` +
|
|
68
|
-
// the prompt for that URL, the same prompt promptFor() writes.
|
|
69
|
-
const enabledAssistants = assistants.filter((a) => options.includes(a.id));
|
|
70
|
-
const assistantHref = (a: (typeof assistants)[number]) => (mdAbsoluteUrl ? a.base + encodeURIComponent(promptFor(mdAbsoluteUrl)) : a.home);
|
|
71
124
|
const showCopy = options.includes('copy');
|
|
72
|
-
|
|
73
|
-
const
|
|
125
|
+
// One option and no "Copy page": that option is the button.
|
|
126
|
+
const single = !showCopy && items.length === 1 ? items[0] : null;
|
|
127
|
+
const hasDropdown = items.length > 0 && !single;
|
|
74
128
|
---
|
|
75
|
-
{(showCopy ||
|
|
76
|
-
<div class="wd-copy-page-split">
|
|
129
|
+
{(showCopy || items.length > 0) && (
|
|
130
|
+
<div class="wd-copy-page-split" data-pagefind-ignore="all">
|
|
77
131
|
{showCopy && (
|
|
78
|
-
<button type="button" class:list={['wd-copy-page-primary', { 'wd-copy-page-alone': !hasDropdown }]} data-md-path={mdPath} aria-label="Copy page as Markdown">
|
|
132
|
+
<button type="button" class:list={['wd-copy-page-primary', { 'wd-copy-page-alone': !hasDropdown }]} data-copy-page data-md-path={mdPath} aria-label="Copy page as Markdown">
|
|
79
133
|
<Icon name="lucide:copy" class="wd-copy-page-icon" />
|
|
80
134
|
<span class="wd-copy-page-primary-label">Copy page</span>
|
|
81
135
|
</button>
|
|
82
136
|
)}
|
|
137
|
+
{single && (single.href ? (
|
|
138
|
+
<a class="wd-copy-page-primary wd-copy-page-alone" href={single.href} target={single.newTab ? '_blank' : undefined} rel={single.newTab ? 'noopener' : undefined} {...single.attrs}>
|
|
139
|
+
<Icon name={single.icon} class="wd-copy-page-icon" />
|
|
140
|
+
<span class="wd-copy-page-primary-label">{single.title}</span>
|
|
141
|
+
</a>
|
|
142
|
+
) : (
|
|
143
|
+
<button type="button" class="wd-copy-page-primary wd-copy-page-alone" {...single.attrs}>
|
|
144
|
+
<Icon name={single.icon} class="wd-copy-page-icon" />
|
|
145
|
+
<span class="wd-copy-page-primary-label">{single.title}</span>
|
|
146
|
+
</button>
|
|
147
|
+
))}
|
|
83
148
|
{hasDropdown && (
|
|
84
149
|
<div class="wd-dropdown wd-copy-page">
|
|
85
|
-
<button type="button" class:list={['wd-dropdown-trigger', 'wd-copy-page-caret-trigger', { 'wd-copy-page-labeled-trigger': !showCopy }]} aria-
|
|
150
|
+
<button type="button" class:list={['wd-dropdown-trigger', 'wd-copy-page-caret-trigger', { 'wd-copy-page-labeled-trigger': !showCopy }]} aria-expanded="false" aria-label={showCopy ? 'More copy options' : 'Page options'}>
|
|
86
151
|
{!showCopy && (
|
|
87
152
|
<>
|
|
88
153
|
<Icon name="lucide:sparkles" class="wd-copy-page-icon" />
|
|
@@ -93,25 +158,29 @@ const hasDropdown = showView || enabledAssistants.length > 0;
|
|
|
93
158
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
|
94
159
|
</svg>
|
|
95
160
|
</button>
|
|
96
|
-
<div class="wd-dropdown-menu wd-copy-page-menu"
|
|
161
|
+
<div class="wd-dropdown-menu wd-copy-page-menu">
|
|
97
162
|
<div class="wd-dropdown-menu-panel wd-copy-page-menu-panel">
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
163
|
+
{items.map((item, i) => (
|
|
164
|
+
<>
|
|
165
|
+
{i > 0 && item.group !== items[i - 1].group && <hr class="wd-copy-page-divider" />}
|
|
166
|
+
{item.href ? (
|
|
167
|
+
<a class="wd-copy-page-item" href={item.href} target={item.newTab ? '_blank' : undefined} rel={item.newTab ? 'noopener' : undefined} {...item.attrs}>
|
|
168
|
+
<Icon name={item.icon} class="wd-copy-page-icon" />
|
|
169
|
+
<span class="wd-copy-page-item-text">
|
|
170
|
+
<span class="wd-copy-page-item-title">{item.title}</span>
|
|
171
|
+
<span class="wd-copy-page-item-desc">{item.desc}</span>
|
|
172
|
+
</span>
|
|
173
|
+
</a>
|
|
174
|
+
) : (
|
|
175
|
+
<button type="button" class="wd-copy-page-item" {...item.attrs}>
|
|
176
|
+
<Icon name={item.icon} class="wd-copy-page-icon" />
|
|
177
|
+
<span class="wd-copy-page-item-text">
|
|
178
|
+
<span class="wd-copy-page-item-title">{item.title}</span>
|
|
179
|
+
<span class="wd-copy-page-item-desc">{item.desc}</span>
|
|
180
|
+
</span>
|
|
181
|
+
</button>
|
|
182
|
+
)}
|
|
183
|
+
</>
|
|
115
184
|
))}
|
|
116
185
|
</div>
|
|
117
186
|
</div>
|
|
@@ -141,6 +210,9 @@ const hasDropdown = showView || enabledAssistants.length > 0;
|
|
|
141
210
|
border: 1px solid var(--wd-border);
|
|
142
211
|
border-radius: 0.4rem;
|
|
143
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;
|
|
144
216
|
}
|
|
145
217
|
/* .wd-copy-page is this instance's own class on the .wd-dropdown div
|
|
146
218
|
wrapping the caret button + menu (see BaseLayout.astro's shared
|
|
@@ -201,6 +273,16 @@ const hasDropdown = showView || enabledAssistants.length > 0;
|
|
|
201
273
|
.wd-copy-page-primary.wd-copy-page-alone {
|
|
202
274
|
border-radius: 0.35rem;
|
|
203
275
|
}
|
|
276
|
+
/* The single-option button can be a link ("Open in Claude"). */
|
|
277
|
+
a.wd-copy-page-primary {
|
|
278
|
+
text-decoration: none;
|
|
279
|
+
}
|
|
280
|
+
/* Between the page's options and the MCP server's. */
|
|
281
|
+
.wd-copy-page-divider {
|
|
282
|
+
margin: 0.3rem 0.4rem;
|
|
283
|
+
border: none;
|
|
284
|
+
border-top: 1px solid var(--wd-border);
|
|
285
|
+
}
|
|
204
286
|
.wd-copy-page-caret-trigger.wd-copy-page-labeled-trigger {
|
|
205
287
|
gap: 0.35rem;
|
|
206
288
|
padding: 0.35rem 0.65rem;
|
|
@@ -259,6 +341,12 @@ const hasDropdown = showView || enabledAssistants.length > 0;
|
|
|
259
341
|
text-align: left;
|
|
260
342
|
cursor: pointer;
|
|
261
343
|
}
|
|
344
|
+
/* A button option ("Copy MCP server URL") fills the row like the links
|
|
345
|
+
do - a button sizes to its content otherwise. */
|
|
346
|
+
button.wd-copy-page-item {
|
|
347
|
+
width: 100%;
|
|
348
|
+
box-sizing: border-box;
|
|
349
|
+
}
|
|
262
350
|
.wd-copy-page-item:hover {
|
|
263
351
|
background: var(--wd-surface);
|
|
264
352
|
}
|
|
@@ -415,6 +415,8 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
|
|
|
415
415
|
<head>
|
|
416
416
|
<meta charset="UTF-8" />
|
|
417
417
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
418
|
+
{/* Marks the pages writedocs renders - the build checks each has the site's styles (cli/check-built-styles.js). */}
|
|
419
|
+
<meta name="generator" content="writedocs" />
|
|
418
420
|
<title>{title} · {config.name}</title>
|
|
419
421
|
<ClientRouter />
|
|
420
422
|
{effectiveDescription && <meta name="description" content={effectiveDescription} />}
|
|
@@ -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>
|