@writedocs/generator 0.8.0 → 0.9.0
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 +6 -2
- package/bin/writedocs.js +12 -7
- package/package.json +1 -1
- package/src/cli/build.js +15 -0
- package/src/cli/check-built-styles.js +33 -0
- package/src/cli/convert.js +3 -0
- package/src/cli/dev.js +13 -1
- package/src/cli/open-browser.js +32 -0
- package/src/cli/run-astro.js +4 -0
- package/src/components/ApiLangSelect.astro +1 -2
- package/src/components/CopyPageMenu.astro +161 -40
- package/src/layout/BaseLayout.astro +2 -0
- package/src/layout/components/TopBar.astro +113 -94
- package/src/layout/styles/search-modal.css +27 -0
- package/src/layout/styles/topbar.css +107 -1
- package/src/lib/canonical-path.js +11 -0
- package/src/lib/config-schema.js +119 -7
- package/src/lib/config-schema.ts +158 -20
- package/src/lib/content-check.js +15 -0
- package/src/lib/json-schema-descriptions.js +2 -2
- package/src/lib/link-check.js +2 -2
- package/src/lib/llms-index.ts +3 -3
- package/src/lib/mcp-links.js +30 -0
- package/src/lib/mintlify-convert.js +7 -4
- package/src/lib/search-scope.js +44 -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 -6
- package/src/pages/[...slug].md.ts +8 -8
- 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 +33 -15
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,
|
|
@@ -107,7 +108,10 @@ const contentPublicDir = path.join(contentDir, 'public');
|
|
|
107
108
|
// document-relative URLs isn't meaningful anyway.
|
|
108
109
|
const siteUrl = resolveSiteUrl(docsConfig);
|
|
109
110
|
if (!siteUrl) {
|
|
110
|
-
report(
|
|
111
|
+
report(
|
|
112
|
+
'info',
|
|
113
|
+
'No "domain" set in writedocs.json, so no sitemap.xml was generated, and llms.txt, the MCP server\'s page URLs and the "Open in…" links in the page menu use relative addresses or the address the page is opened from. Set "domain" to the site\'s address, like "docs.example.com".'
|
|
114
|
+
);
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
/** Recursively lists every .md/.mdx file under `baseDir` (Node 20's
|
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
|
|
@@ -61,12 +64,14 @@ program
|
|
|
61
64
|
.argument('[dir]', 'content directory (contains writedocs.json and docs/)', '.')
|
|
62
65
|
.option('-p, --port <port>', 'port to run on')
|
|
63
66
|
.option('--verbose', "also print Astro's and Vite's own output")
|
|
67
|
+
.option('--no-open', "don't open the preview in the browser")
|
|
64
68
|
.action(async (dir, opts) => {
|
|
65
69
|
await runDev({
|
|
66
|
-
contentDir: path.resolve(process.cwd(), dir),
|
|
70
|
+
contentDir: canonicalPath(path.resolve(process.cwd(), dir)),
|
|
67
71
|
packageRoot,
|
|
68
72
|
port: opts.port,
|
|
69
73
|
verbose: Boolean(opts.verbose),
|
|
74
|
+
open: opts.open,
|
|
70
75
|
});
|
|
71
76
|
});
|
|
72
77
|
|
|
@@ -84,7 +89,7 @@ program
|
|
|
84
89
|
.option('-k, --key <key>', 'build authorization key (or set WRITEDOCS_API_KEY)')
|
|
85
90
|
.option('--verbose', "also print Astro's and Vite's own output")
|
|
86
91
|
.action(async (dir, opts) => {
|
|
87
|
-
const contentDir = path.resolve(process.cwd(), dir);
|
|
92
|
+
const contentDir = canonicalPath(path.resolve(process.cwd(), dir));
|
|
88
93
|
// Project-scoped, not global: a .env sitting next to this project's own
|
|
89
94
|
// writedocs.json (WRITEDOCS_API_KEY) is picked up automatically, so
|
|
90
95
|
// build doesn't need it exported by hand every
|
|
@@ -104,7 +109,7 @@ program
|
|
|
104
109
|
.argument('[dir]', 'content directory (contains writedocs.json)', '.')
|
|
105
110
|
.option('--config-only', 'check writedocs.json only, not the pages')
|
|
106
111
|
.action(async (dir, options) => {
|
|
107
|
-
const contentDir = path.resolve(process.cwd(), dir);
|
|
112
|
+
const contentDir = canonicalPath(path.resolve(process.cwd(), dir));
|
|
108
113
|
const configPath = path.join(contentDir, 'writedocs.json');
|
|
109
114
|
if (!fs.existsSync(configPath)) {
|
|
110
115
|
// Mesma mensagem que loadDocsConfig() ja da pro mesmo caso.
|
|
@@ -190,7 +195,7 @@ program
|
|
|
190
195
|
.description('Check every internal link - pages, anchors and files - in the pages and writedocs.json')
|
|
191
196
|
.argument('[dir]', 'content directory (contains writedocs.json)', '.')
|
|
192
197
|
.action(async (dir) => {
|
|
193
|
-
const contentDir = path.resolve(process.cwd(), dir);
|
|
198
|
+
const contentDir = canonicalPath(path.resolve(process.cwd(), dir));
|
|
194
199
|
const { preflightCheck } = await import('../src/cli/preflight.js');
|
|
195
200
|
preflightCheck(contentDir);
|
|
196
201
|
const configText = readConfigText(contentDir);
|
|
@@ -225,7 +230,7 @@ program
|
|
|
225
230
|
.description('Check accessibility - color contrast, image alt text, headings, link text')
|
|
226
231
|
.argument('[dir]', 'content directory (contains writedocs.json)', '.')
|
|
227
232
|
.action(async (dir) => {
|
|
228
|
-
const contentDir = path.resolve(process.cwd(), dir);
|
|
233
|
+
const contentDir = canonicalPath(path.resolve(process.cwd(), dir));
|
|
229
234
|
const { preflightCheck } = await import('../src/cli/preflight.js');
|
|
230
235
|
preflightCheck(contentDir);
|
|
231
236
|
const configText = readConfigText(contentDir);
|
|
@@ -274,7 +279,7 @@ program
|
|
|
274
279
|
const { runConvert } = await import('../src/cli/convert.js');
|
|
275
280
|
await runConvert({
|
|
276
281
|
source: legacy ? 'writedocs' : 'mintlify',
|
|
277
|
-
contentDir: path.resolve(process.cwd(), dir),
|
|
282
|
+
contentDir: canonicalPath(path.resolve(process.cwd(), dir)),
|
|
278
283
|
force: Boolean(options.force),
|
|
279
284
|
dryRun: Boolean(options.dryRun),
|
|
280
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';
|
|
@@ -6,12 +7,16 @@ import { preflightCheck, sameDriveCheck, writableInstallCheck } from './prefligh
|
|
|
6
7
|
import { generateApiPages } from './generate-api-pages.js';
|
|
7
8
|
import { writeRedirectsFile } from './write-redirects-file.js';
|
|
8
9
|
import { writeMcpFiles } from './write-mcp-files.js';
|
|
10
|
+
import { pagesWithoutStyles } from './check-built-styles.js';
|
|
9
11
|
import { writedocsBuildStagingDir } from '../lib/writedocs-temp-dir.js';
|
|
10
12
|
import { log, step, plural, duration, displayPath, formatProblems, color, CliExit } from './output.js';
|
|
11
13
|
import { describeError, builtRoute, authorWarning, verboseLine } from './astro-output.js';
|
|
12
14
|
import { reportApiPages } from './api-pages-output.js';
|
|
13
15
|
|
|
14
16
|
export async function runBuild({ contentDir, packageRoot, verbose = false }) {
|
|
17
|
+
// One spelling of each folder throughout - see lib/canonical-path.js.
|
|
18
|
+
contentDir = canonicalPath(contentDir);
|
|
19
|
+
packageRoot = canonicalPath(packageRoot);
|
|
15
20
|
const started = Date.now();
|
|
16
21
|
preflightCheck(contentDir);
|
|
17
22
|
writableInstallCheck(packageRoot);
|
|
@@ -108,6 +113,16 @@ export async function runBuild({ contentDir, packageRoot, verbose = false }) {
|
|
|
108
113
|
fs.rmSync(distDir, { recursive: true, force: true });
|
|
109
114
|
fs.cpSync(stagingDir, distDir, { recursive: true });
|
|
110
115
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
116
|
+
// Every page writedocs renders links the site's stylesheet. Builds have
|
|
117
|
+
// come out without it on every page while reporting success (the package
|
|
118
|
+
// folder spelled `c:`, fixed in lib/canonical-path.js) - so it's checked
|
|
119
|
+
// here, and a build like that stops instead of being deployed unstyled.
|
|
120
|
+
const unstyled = pagesWithoutStyles(distDir);
|
|
121
|
+
if (unstyled.length) {
|
|
122
|
+
log.error(`The build produced ${plural(unstyled.length, 'page')} without the site's styles - for example ${unstyled[0]}.`);
|
|
123
|
+
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.'));
|
|
124
|
+
throw new CliExit(1);
|
|
125
|
+
}
|
|
111
126
|
// Astro's own writedocs.json `redirects` + the automatic "/" redirect only
|
|
112
127
|
// ever produce client-side meta-refresh pages (see write-redirects-file.js)
|
|
113
128
|
// - this turns those into real instant edge redirects on hosts that read
|
|
@@ -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/convert.js
CHANGED
|
@@ -96,6 +96,9 @@ export async function runConvert({ source = 'mintlify', contentDir, force = fals
|
|
|
96
96
|
// before the first build.
|
|
97
97
|
const checking = step('Checking pages');
|
|
98
98
|
const content = await checkContent(contentDir, text);
|
|
99
|
+
// The domain reminder comes once, at the end, with where the source tool
|
|
100
|
+
// kept that setting.
|
|
101
|
+
content.warnings = content.warnings.filter((w) => w.code !== 'no-domain');
|
|
99
102
|
checking.stop();
|
|
100
103
|
if (content.errors.length) {
|
|
101
104
|
log.line();
|
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';
|
|
@@ -6,13 +7,17 @@ import { describeError, requestLog, authorWarning, verboseLine, stripAnsi } from
|
|
|
6
7
|
import { reportApiPages } from './api-pages-output.js';
|
|
7
8
|
import { runningPreview, writeLock, removeLock } from './dev-lock.js';
|
|
8
9
|
import { showUpdateNotice } from './update-check.js';
|
|
10
|
+
import { shouldOpenBrowser, openBrowser } from './open-browser.js';
|
|
9
11
|
|
|
10
12
|
// The same problem tends to arrive more than once in a row - Vite and
|
|
11
13
|
// Astro each log a failed page, and a page compiles for more than one
|
|
12
14
|
// environment. Within this window, a repeat is dropped.
|
|
13
15
|
const REPEAT_WINDOW_MS = 2000;
|
|
14
16
|
|
|
15
|
-
export async function runDev({ contentDir, packageRoot, port, verbose = false }) {
|
|
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);
|
|
16
21
|
const started = Date.now();
|
|
17
22
|
preflightCheck(contentDir);
|
|
18
23
|
writableInstallCheck(packageRoot);
|
|
@@ -36,6 +41,7 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false })
|
|
|
36
41
|
|
|
37
42
|
const starting = step('Starting local preview');
|
|
38
43
|
let ready = false;
|
|
44
|
+
let browserOpened = false;
|
|
39
45
|
|
|
40
46
|
const lastShown = new Map();
|
|
41
47
|
const once = (key, print) => {
|
|
@@ -102,6 +108,12 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false })
|
|
|
102
108
|
// `dev` runs until Ctrl+C - the notice goes under the ready screen,
|
|
103
109
|
// not after the command like everywhere else.
|
|
104
110
|
showUpdateNotice();
|
|
111
|
+
// The preview in the browser, at the address it actually got - once,
|
|
112
|
+
// not again if Astro restarts the server.
|
|
113
|
+
if (!browserOpened && shouldOpenBrowser({ open })) {
|
|
114
|
+
browserOpened = true;
|
|
115
|
+
openBrowser(local ?? `http://localhost:${wanted}/`);
|
|
116
|
+
}
|
|
105
117
|
return;
|
|
106
118
|
}
|
|
107
119
|
case 'fatal':
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// `writedocs dev` opens the preview in the default browser once it's ready.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
/** Whether to open the browser: not with `--no-open`, not with BROWSER=none
|
|
5
|
+
* (the convention Vite and create-react-app follow), and not where nobody's
|
|
6
|
+
* watching - CI, or output that isn't an interactive terminal. */
|
|
7
|
+
export function shouldOpenBrowser({ open = true, env = process.env, isTTY = Boolean(process.stdout.isTTY) } = {}) {
|
|
8
|
+
if (!open || !isTTY || env.CI) return false;
|
|
9
|
+
return String(env.BROWSER ?? '').toLowerCase() !== 'none';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The command that opens `url` in the default browser on `platform`. On
|
|
13
|
+
* Windows, `start`'s first quoted argument is a window title - hence the
|
|
14
|
+
* empty one, which Node passes as "". */
|
|
15
|
+
export function openCommand(url, platform = process.platform) {
|
|
16
|
+
if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]];
|
|
17
|
+
if (platform === 'darwin') return ['open', [url]];
|
|
18
|
+
return ['xdg-open', [url]];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Opens `url`, best effort: a machine with no browser (or no xdg-open)
|
|
22
|
+
* just doesn't get one - the URL is printed anyway. */
|
|
23
|
+
export function openBrowser(url, { platform = process.platform, run = spawn } = {}) {
|
|
24
|
+
const [command, args] = openCommand(url, platform);
|
|
25
|
+
try {
|
|
26
|
+
const child = run(command, args, { stdio: 'ignore', detached: true, windowsHide: true });
|
|
27
|
+
child.on?.('error', () => {});
|
|
28
|
+
child.unref?.();
|
|
29
|
+
} catch {
|
|
30
|
+
// no browser to open - fine
|
|
31
|
+
}
|
|
32
|
+
}
|
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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
// The "Copy page" split button -
|
|
3
|
-
//
|
|
2
|
+
// The "Copy page" split button - on by default, showing the options
|
|
3
|
+
// writedocs.json's `contextMenu` leaves on (`options`, from
|
|
4
|
+
// contextMenuOptions() in lib/config-schema.ts). With `copy`, a primary button
|
|
4
5
|
// (always-visible "Copy page" action, copies this page's Markdown
|
|
5
6
|
// straight to the clipboard on click - see initCopyPageMenu() in
|
|
6
7
|
// [...slug].astro) plus a small caret-only button next to it that opens
|
|
@@ -19,19 +20,29 @@
|
|
|
19
20
|
// Rendered from [...slug].astro (see its own comment on where/when),
|
|
20
21
|
// which passes in everything needed to build both same-origin links (the
|
|
21
22
|
// .md route from [...slug].md.ts) and the external "Open in X" links
|
|
22
|
-
// (which need an absolute URL -
|
|
23
|
-
//
|
|
24
|
-
//
|
|
23
|
+
// (which need an absolute URL - from writedocs.json's `domain` when it's
|
|
24
|
+
// set, else from the page's own address in the browser; see
|
|
25
|
+
// enabledAssistants below).
|
|
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.
|
|
25
34
|
import { Icon } from 'astro-icon/components';
|
|
26
|
-
import type {
|
|
35
|
+
import type { ContextMenuOption } from '../lib/config';
|
|
36
|
+
import { mcpServerUrl, cursorInstallLink, vscodeInstallLink } from '../lib/mcp-links.js';
|
|
27
37
|
|
|
28
38
|
interface Props {
|
|
29
39
|
currentPath: string; // e.g. "/" or "/guides/foo/" - see hrefForSlug() in [...slug].astro
|
|
30
40
|
siteUrl: string | null;
|
|
31
|
-
|
|
41
|
+
siteName: string;
|
|
42
|
+
options: ContextMenuOption[];
|
|
32
43
|
}
|
|
33
44
|
|
|
34
|
-
const { currentPath, siteUrl,
|
|
45
|
+
const { currentPath, siteUrl, siteName, options } = Astro.props as Props;
|
|
35
46
|
|
|
36
47
|
// The [...slug].md.ts route this page is also served at - same
|
|
37
48
|
// normalizeEntryId()-driven convention that file uses to build its own
|
|
@@ -39,6 +50,7 @@ const { currentPath, siteUrl, contextMenu } = Astro.props as Props;
|
|
|
39
50
|
// slug with a literal ".md" appended instead of the trailing slash).
|
|
40
51
|
const mdPath = currentPath === '/' ? '/index.md' : `${currentPath.replace(/\/$/, '')}.md`;
|
|
41
52
|
const mdAbsoluteUrl = siteUrl ? `${siteUrl}${mdPath}` : null;
|
|
53
|
+
const mcpUrl = siteUrl ? mcpServerUrl(siteUrl) : null;
|
|
42
54
|
|
|
43
55
|
// Well-known query-string conventions for starting a new conversation
|
|
44
56
|
// pre-seeded with a prompt (not an official API for any of the three -
|
|
@@ -50,53 +62,132 @@ const mdAbsoluteUrl = siteUrl ? `${siteUrl}${mdPath}` : null;
|
|
|
50
62
|
// a bookmarked/shared link.
|
|
51
63
|
const promptFor = (url: string) => `Read ${url} so you can answer questions about it.`;
|
|
52
64
|
|
|
65
|
+
// `base` + the URL-encoded prompt is the link; `home` is where it goes
|
|
66
|
+
// before the page script has filled the prompt in (see below).
|
|
53
67
|
const assistants = [
|
|
54
|
-
{ id: 'chatgpt' as const, label: 'ChatGPT', icon: 'simple-icons:openai',
|
|
55
|
-
{ id: 'claude' as const, label: 'Claude', icon: 'simple-icons:claude',
|
|
56
|
-
{ id: 'perplexity' as const, label: 'Perplexity', icon: 'simple-icons:perplexity',
|
|
68
|
+
{ id: 'chatgpt' as const, label: 'ChatGPT', icon: 'simple-icons:openai', base: 'https://chatgpt.com/?q=', home: 'https://chatgpt.com/' },
|
|
69
|
+
{ id: 'claude' as const, label: 'Claude', icon: 'simple-icons:claude', base: 'https://claude.ai/new?q=', home: 'https://claude.ai/new' },
|
|
70
|
+
{ id: 'perplexity' as const, label: 'Perplexity', icon: 'simple-icons:perplexity', base: 'https://www.perplexity.ai/search?q=', home: 'https://www.perplexity.ai/' },
|
|
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' },
|
|
57
75
|
];
|
|
58
76
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// it
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
:
|
|
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
|
+
}
|
|
123
|
+
|
|
124
|
+
const showCopy = options.includes('copy');
|
|
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;
|
|
66
128
|
---
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
<
|
|
71
|
-
|
|
129
|
+
{(showCopy || items.length > 0) && (
|
|
130
|
+
<div class="wd-copy-page-split" data-pagefind-ignore="all">
|
|
131
|
+
{showCopy && (
|
|
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">
|
|
133
|
+
<Icon name="lucide:copy" class="wd-copy-page-icon" />
|
|
134
|
+
<span class="wd-copy-page-primary-label">Copy page</span>
|
|
135
|
+
</button>
|
|
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
|
+
))}
|
|
148
|
+
{hasDropdown && (
|
|
72
149
|
<div class="wd-dropdown wd-copy-page">
|
|
73
|
-
<button type="button" class=
|
|
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'}>
|
|
151
|
+
{!showCopy && (
|
|
152
|
+
<>
|
|
153
|
+
<Icon name="lucide:sparkles" class="wd-copy-page-icon" />
|
|
154
|
+
<span class="wd-copy-page-primary-label">Page options</span>
|
|
155
|
+
</>
|
|
156
|
+
)}
|
|
74
157
|
<svg class="wd-dropdown-caret" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
|
75
158
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
|
76
159
|
</svg>
|
|
77
160
|
</button>
|
|
78
|
-
<div class="wd-dropdown-menu wd-copy-page-menu"
|
|
161
|
+
<div class="wd-dropdown-menu wd-copy-page-menu">
|
|
79
162
|
<div class="wd-dropdown-menu-panel wd-copy-page-menu-panel">
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
</>
|
|
95
184
|
))}
|
|
96
185
|
</div>
|
|
97
186
|
</div>
|
|
98
187
|
</div>
|
|
188
|
+
)}
|
|
99
189
|
</div>
|
|
190
|
+
)}
|
|
100
191
|
|
|
101
192
|
<style>
|
|
102
193
|
/* The split button: a primary "Copy page" action (always visible,
|
|
@@ -174,6 +265,30 @@ const enabledAssistants = mdAbsoluteUrl
|
|
|
174
265
|
border-left: 1px solid var(--wd-border);
|
|
175
266
|
border-radius: 0 0.35rem 0.35rem 0;
|
|
176
267
|
}
|
|
268
|
+
/* Only one of the two halves: it gets all four corners (and, for the
|
|
269
|
+
trigger, no divider line and a label like the primary button's). */
|
|
270
|
+
.wd-copy-page-primary.wd-copy-page-alone {
|
|
271
|
+
border-radius: 0.35rem;
|
|
272
|
+
}
|
|
273
|
+
/* The single-option button can be a link ("Open in Claude"). */
|
|
274
|
+
a.wd-copy-page-primary {
|
|
275
|
+
text-decoration: none;
|
|
276
|
+
}
|
|
277
|
+
/* Between the page's options and the MCP server's. */
|
|
278
|
+
.wd-copy-page-divider {
|
|
279
|
+
margin: 0.3rem 0.4rem;
|
|
280
|
+
border: none;
|
|
281
|
+
border-top: 1px solid var(--wd-border);
|
|
282
|
+
}
|
|
283
|
+
.wd-copy-page-caret-trigger.wd-copy-page-labeled-trigger {
|
|
284
|
+
gap: 0.35rem;
|
|
285
|
+
padding: 0.35rem 0.65rem;
|
|
286
|
+
border-left: none;
|
|
287
|
+
border-radius: 0.35rem;
|
|
288
|
+
color: var(--wd-text-muted);
|
|
289
|
+
font-size: 0.85rem;
|
|
290
|
+
font-weight: 500;
|
|
291
|
+
}
|
|
177
292
|
.wd-copy-page-menu {
|
|
178
293
|
left: auto;
|
|
179
294
|
right: 0;
|
|
@@ -223,6 +338,12 @@ const enabledAssistants = mdAbsoluteUrl
|
|
|
223
338
|
text-align: left;
|
|
224
339
|
cursor: pointer;
|
|
225
340
|
}
|
|
341
|
+
/* A button option ("Copy MCP server URL") fills the row like the links
|
|
342
|
+
do - a button sizes to its content otherwise. */
|
|
343
|
+
button.wd-copy-page-item {
|
|
344
|
+
width: 100%;
|
|
345
|
+
box-sizing: border-box;
|
|
346
|
+
}
|
|
226
347
|
.wd-copy-page-item:hover {
|
|
227
348
|
background: var(--wd-surface);
|
|
228
349
|
}
|
|
@@ -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} />}
|