@writedocs/generator 0.7.4 → 0.8.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 +8 -1
- package/bin/writedocs.js +2 -0
- package/package.json +3 -2
- package/src/cli/build.js +8 -0
- package/src/cli/convert.js +3 -0
- package/src/cli/dev.js +9 -1
- package/src/cli/open-browser.js +32 -0
- package/src/cli/write-mcp-files.js +96 -0
- package/src/components/CopyPageMenu.astro +60 -24
- package/src/lib/agent-markdown.js +9 -4
- package/src/lib/config-schema.js +29 -5
- package/src/lib/config-schema.ts +48 -18
- package/src/lib/content-check.js +15 -0
- package/src/lib/json-schema-descriptions.js +3 -2
- package/src/lib/link-check.js +2 -2
- package/src/lib/llms-index.ts +3 -3
- package/src/lib/mcp-dev-integration.js +60 -0
- package/src/lib/mcp-index.ts +101 -0
- package/src/lib/mintlify-convert.js +7 -4
- package/src/mcp/server.js +262 -0
- package/src/pages/[...slug].astro +21 -5
- package/src/pages/[...slug].md.ts +8 -8
- package/src/pages/[mcpIndex].json.ts +16 -0
- package/writedocs.schema.json +35 -14
package/astro.config.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { remarkUnknownComponentFallback } from './src/lib/mdx-unknown-components
|
|
|
36
36
|
import { remarkExtractInlineReactComponents } from './src/lib/mdx-inline-react.js';
|
|
37
37
|
import { writedocsTempDir, writedocsBuildStagingDir } from './src/lib/writedocs-temp-dir.js';
|
|
38
38
|
import { stylesAssetFallback } from './src/lib/styles-asset-integration.js';
|
|
39
|
+
import { mcpDevServer } from './src/lib/mcp-dev-integration.js';
|
|
39
40
|
import { report } from './src/lib/cli-report.js';
|
|
40
41
|
import {
|
|
41
42
|
loadDocsConfig,
|
|
@@ -106,7 +107,10 @@ const contentPublicDir = path.join(contentDir, 'public');
|
|
|
106
107
|
// document-relative URLs isn't meaningful anyway.
|
|
107
108
|
const siteUrl = resolveSiteUrl(docsConfig);
|
|
108
109
|
if (!siteUrl) {
|
|
109
|
-
report(
|
|
110
|
+
report(
|
|
111
|
+
'info',
|
|
112
|
+
'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".'
|
|
113
|
+
);
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
/** Recursively lists every .md/.mdx file under `baseDir` (Node 20's
|
|
@@ -309,6 +313,9 @@ export default defineConfig({
|
|
|
309
313
|
// field list and styles-asset-integration.js for how it's actually
|
|
310
314
|
// served/copied.
|
|
311
315
|
stylesAssetFallback(contentDir),
|
|
316
|
+
// /mcp in `writedocs dev`, as the build's dist/_worker.js serves it -
|
|
317
|
+
// see src/lib/mcp-dev-integration.js.
|
|
318
|
+
mcpDevServer({ version: JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8')).version }),
|
|
312
319
|
],
|
|
313
320
|
output: 'static',
|
|
314
321
|
// Dual Shiki themes for fenced code blocks (```) in MDX content, so
|
package/bin/writedocs.js
CHANGED
|
@@ -61,12 +61,14 @@ program
|
|
|
61
61
|
.argument('[dir]', 'content directory (contains writedocs.json and docs/)', '.')
|
|
62
62
|
.option('-p, --port <port>', 'port to run on')
|
|
63
63
|
.option('--verbose', "also print Astro's and Vite's own output")
|
|
64
|
+
.option('--no-open', "don't open the preview in the browser")
|
|
64
65
|
.action(async (dir, opts) => {
|
|
65
66
|
await runDev({
|
|
66
67
|
contentDir: path.resolve(process.cwd(), dir),
|
|
67
68
|
packageRoot,
|
|
68
69
|
port: opts.port,
|
|
69
70
|
verbose: Boolean(opts.verbose),
|
|
71
|
+
open: opts.open,
|
|
70
72
|
});
|
|
71
73
|
});
|
|
72
74
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@writedocs/generator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Static site generator for docs — a writedocs.json + MDX folder in, a static site out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"exports": {
|
|
10
10
|
"./components": "./src/components/index.ts",
|
|
11
11
|
"./config-schema": "./src/lib/config-schema.js",
|
|
12
|
-
"./writedocs.schema.json": "./writedocs.schema.json"
|
|
12
|
+
"./writedocs.schema.json": "./writedocs.schema.json",
|
|
13
|
+
"./mcp": "./src/mcp/server.js"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"bin",
|
package/src/cli/build.js
CHANGED
|
@@ -5,6 +5,7 @@ import { runPagefind } from './run-pagefind.js';
|
|
|
5
5
|
import { preflightCheck, sameDriveCheck, writableInstallCheck } from './preflight.js';
|
|
6
6
|
import { generateApiPages } from './generate-api-pages.js';
|
|
7
7
|
import { writeRedirectsFile } from './write-redirects-file.js';
|
|
8
|
+
import { writeMcpFiles } from './write-mcp-files.js';
|
|
8
9
|
import { writedocsBuildStagingDir } from '../lib/writedocs-temp-dir.js';
|
|
9
10
|
import { log, step, plural, duration, displayPath, formatProblems, color, CliExit } from './output.js';
|
|
10
11
|
import { describeError, builtRoute, authorWarning, verboseLine } from './astro-output.js';
|
|
@@ -112,6 +113,13 @@ export async function runBuild({ contentDir, packageRoot, verbose = false }) {
|
|
|
112
113
|
// - this turns those into real instant edge redirects on hosts that read
|
|
113
114
|
// a `_redirects` file (Cloudflare Pages, Netlify), purely additively.
|
|
114
115
|
writeRedirectsFile(distDir, contentDir);
|
|
116
|
+
// The site's MCP server at /mcp: mcp-index.json is already in dist/ (an
|
|
117
|
+
// Astro route); this adds the Cloudflare _worker.js that serves it - see
|
|
118
|
+
// write-mcp-files.js for every host.
|
|
119
|
+
const { version: writedocsVersion } = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
|
|
120
|
+
const mcp = writeMcpFiles(distDir, contentDir, writedocsVersion);
|
|
121
|
+
if (mcp.written.length) notes.push('MCP server at /mcp - dist/_worker.js runs it on Cloudflare; mcp-index.json holds the pages.');
|
|
122
|
+
if (mcp.skipped) addWarning(null, `No _worker.js for the MCP server: ${mcp.skipped}.`);
|
|
115
123
|
|
|
116
124
|
const indexing = step('Indexing search');
|
|
117
125
|
try {
|
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
|
@@ -6,13 +6,14 @@ import { describeError, requestLog, authorWarning, verboseLine, stripAnsi } from
|
|
|
6
6
|
import { reportApiPages } from './api-pages-output.js';
|
|
7
7
|
import { runningPreview, writeLock, removeLock } from './dev-lock.js';
|
|
8
8
|
import { showUpdateNotice } from './update-check.js';
|
|
9
|
+
import { shouldOpenBrowser, openBrowser } from './open-browser.js';
|
|
9
10
|
|
|
10
11
|
// The same problem tends to arrive more than once in a row - Vite and
|
|
11
12
|
// Astro each log a failed page, and a page compiles for more than one
|
|
12
13
|
// environment. Within this window, a repeat is dropped.
|
|
13
14
|
const REPEAT_WINDOW_MS = 2000;
|
|
14
15
|
|
|
15
|
-
export async function runDev({ contentDir, packageRoot, port, verbose = false }) {
|
|
16
|
+
export async function runDev({ contentDir, packageRoot, port, verbose = false, open = true }) {
|
|
16
17
|
const started = Date.now();
|
|
17
18
|
preflightCheck(contentDir);
|
|
18
19
|
writableInstallCheck(packageRoot);
|
|
@@ -36,6 +37,7 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false })
|
|
|
36
37
|
|
|
37
38
|
const starting = step('Starting local preview');
|
|
38
39
|
let ready = false;
|
|
40
|
+
let browserOpened = false;
|
|
39
41
|
|
|
40
42
|
const lastShown = new Map();
|
|
41
43
|
const once = (key, print) => {
|
|
@@ -102,6 +104,12 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false })
|
|
|
102
104
|
// `dev` runs until Ctrl+C - the notice goes under the ready screen,
|
|
103
105
|
// not after the command like everywhere else.
|
|
104
106
|
showUpdateNotice();
|
|
107
|
+
// The preview in the browser, at the address it actually got - once,
|
|
108
|
+
// not again if Astro restarts the server.
|
|
109
|
+
if (!browserOpened && shouldOpenBrowser({ open })) {
|
|
110
|
+
browserOpened = true;
|
|
111
|
+
openBrowser(local ?? `http://localhost:${wanted}/`);
|
|
112
|
+
}
|
|
105
113
|
return;
|
|
106
114
|
}
|
|
107
115
|
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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// After `writedocs build`: the files that make dist/ serve the site's MCP
|
|
2
|
+
// server at /mcp on Cloudflare, with no setup of the host's own - next to
|
|
3
|
+
// mcp-index.json, which the build itself writes (src/pages/[mcpIndex].json.ts).
|
|
4
|
+
//
|
|
5
|
+
// _worker.js the MCP server (src/mcp/server.js, inlined - no imports)
|
|
6
|
+
// plus a small fetch handler: /mcp goes to the server,
|
|
7
|
+
// everything else to the static files (env.ASSETS).
|
|
8
|
+
// - Cloudflare Pages runs it on its own ("advanced mode").
|
|
9
|
+
// - Cloudflare Workers with static assets: point `main` at
|
|
10
|
+
// dist/_worker.js, assets at dist/ with an ASSETS binding.
|
|
11
|
+
// _routes.json Pages: only /mcp runs the worker; every other path stays a
|
|
12
|
+
// plain static file (and `_redirects` keeps applying).
|
|
13
|
+
// .assetsignore Workers: don't publish _worker.js / _routes.json as files.
|
|
14
|
+
//
|
|
15
|
+
// Anywhere else - Netlify, Vercel, the WriteDocs platform's serving Worker -
|
|
16
|
+
// import handleMcpHttp from `@writedocs/generator/mcp` and give it
|
|
17
|
+
// mcp-index.json; a purely static host (GitHub Pages, S3) can't run /mcp.
|
|
18
|
+
//
|
|
19
|
+
// A project that brings its own _worker.js or _routes.json (in public/), or a
|
|
20
|
+
// Pages `functions/` folder (which Pages ignores once a _worker.js exists),
|
|
21
|
+
// keeps them: the worker isn't written, and the build says so.
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const SERVER_SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'mcp', 'server.js');
|
|
27
|
+
const IGNORED_BY_WORKERS = ['_worker.js', '_routes.json'];
|
|
28
|
+
|
|
29
|
+
/** The `_worker.js` source: server.js without its `export`s, and the fetch
|
|
30
|
+
* handler. `version` is the writedocs version, reported by the server. */
|
|
31
|
+
export function mcpWorkerSource(version) {
|
|
32
|
+
const server = fs.readFileSync(SERVER_SOURCE, 'utf-8').replace(/^export (?=(?:const|let|async function|function) )/gm, '');
|
|
33
|
+
return `// Generated by writedocs ${version} - the site's MCP server at /mcp.
|
|
34
|
+
// Cloudflare Pages runs this file on its own; on Cloudflare Workers, use it as
|
|
35
|
+
// \`main\` with dist/ as the static assets (binding ASSETS). See
|
|
36
|
+
// https://github.com/writedocs/writedocs (src/cli/write-mcp-files.js).
|
|
37
|
+
|
|
38
|
+
${server}
|
|
39
|
+
const WRITEDOCS_VERSION = ${JSON.stringify(version)};
|
|
40
|
+
|
|
41
|
+
// The page index, read once per isolate from the site's own static files.
|
|
42
|
+
let indexPromise = null;
|
|
43
|
+
function loadIndex(request, env) {
|
|
44
|
+
if (!indexPromise) {
|
|
45
|
+
indexPromise = env.ASSETS.fetch(new URL('/mcp-index.json', request.url))
|
|
46
|
+
.then((res) => {
|
|
47
|
+
if (!res.ok) throw new Error('mcp-index.json: HTTP ' + res.status);
|
|
48
|
+
return res.json();
|
|
49
|
+
})
|
|
50
|
+
.catch((err) => {
|
|
51
|
+
indexPromise = null;
|
|
52
|
+
throw err;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return indexPromise;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default {
|
|
59
|
+
async fetch(request, env) {
|
|
60
|
+
const { pathname } = new URL(request.url);
|
|
61
|
+
if (pathname === '/mcp' || pathname === '/mcp/') {
|
|
62
|
+
return handleMcpHttp(request, { loadIndex: () => loadIndex(request, env), version: WRITEDOCS_VERSION });
|
|
63
|
+
}
|
|
64
|
+
return env.ASSETS.fetch(request);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Writes the files into `distDir`. Returns { written: [names], skipped:
|
|
71
|
+
* reason or null } - nothing at all when there's no mcp-index.json
|
|
72
|
+
* (writedocs.json `"mcp": false`). */
|
|
73
|
+
export function writeMcpFiles(distDir, contentDir, version) {
|
|
74
|
+
if (!fs.existsSync(path.join(distDir, 'mcp-index.json'))) return { written: [], skipped: null };
|
|
75
|
+
|
|
76
|
+
const own = ['_worker.js', '_routes.json'].filter((name) => fs.existsSync(path.join(distDir, name)));
|
|
77
|
+
if (own.length) {
|
|
78
|
+
return { written: [], skipped: `the project has its own ${own.join(' and ')} - /mcp needs its handler added there (see @writedocs/generator/mcp)` };
|
|
79
|
+
}
|
|
80
|
+
if (fs.existsSync(path.join(contentDir, 'functions'))) {
|
|
81
|
+
return { written: [], skipped: 'the project has a Cloudflare Pages functions/ folder, which a _worker.js would switch off - add /mcp there with @writedocs/generator/mcp' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fs.writeFileSync(path.join(distDir, '_worker.js'), mcpWorkerSource(version));
|
|
85
|
+
fs.writeFileSync(path.join(distDir, '_routes.json'), JSON.stringify({ version: 1, include: ['/mcp', '/mcp/'], exclude: [] }, null, 2) + '\n');
|
|
86
|
+
|
|
87
|
+
const ignorePath = path.join(distDir, '.assetsignore');
|
|
88
|
+
const existing = fs.existsSync(ignorePath) ? fs.readFileSync(ignorePath, 'utf-8') : '';
|
|
89
|
+
const lines = existing.split(/\r?\n/).map((l) => l.trim());
|
|
90
|
+
const missing = IGNORED_BY_WORKERS.filter((name) => !lines.includes(name));
|
|
91
|
+
if (missing.length) {
|
|
92
|
+
const prefix = existing && !existing.endsWith('\n') ? `${existing}\n` : existing;
|
|
93
|
+
fs.writeFileSync(ignorePath, `${prefix}${missing.join('\n')}\n`);
|
|
94
|
+
}
|
|
95
|
+
return { written: ['_worker.js', '_routes.json', '.assetsignore'], skipped: null };
|
|
96
|
+
}
|
|
@@ -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,21 @@
|
|
|
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 dropdown gets a labeled trigger of its own; with only
|
|
27
|
+
// `copy`, the primary button stands alone.
|
|
25
28
|
import { Icon } from 'astro-icon/components';
|
|
26
|
-
import type {
|
|
29
|
+
import type { ContextMenuOption } from '../lib/config';
|
|
27
30
|
|
|
28
31
|
interface Props {
|
|
29
32
|
currentPath: string; // e.g. "/" or "/guides/foo/" - see hrefForSlug() in [...slug].astro
|
|
30
33
|
siteUrl: string | null;
|
|
31
|
-
|
|
34
|
+
options: ContextMenuOption[];
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
const { currentPath, siteUrl,
|
|
37
|
+
const { currentPath, siteUrl, options } = Astro.props as Props;
|
|
35
38
|
|
|
36
39
|
// The [...slug].md.ts route this page is also served at - same
|
|
37
40
|
// normalizeEntryId()-driven convention that file uses to build its own
|
|
@@ -50,33 +53,49 @@ const mdAbsoluteUrl = siteUrl ? `${siteUrl}${mdPath}` : null;
|
|
|
50
53
|
// a bookmarked/shared link.
|
|
51
54
|
const promptFor = (url: string) => `Read ${url} so you can answer questions about it.`;
|
|
52
55
|
|
|
56
|
+
// `base` + the URL-encoded prompt is the link; `home` is where it goes
|
|
57
|
+
// before the page script has filled the prompt in (see below).
|
|
53
58
|
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',
|
|
59
|
+
{ id: 'chatgpt' as const, label: 'ChatGPT', icon: 'simple-icons:openai', base: 'https://chatgpt.com/?q=', home: 'https://chatgpt.com/' },
|
|
60
|
+
{ id: 'claude' as const, label: 'Claude', icon: 'simple-icons:claude', base: 'https://claude.ai/new?q=', home: 'https://claude.ai/new' },
|
|
61
|
+
{ id: 'perplexity' as const, label: 'Perplexity', icon: 'simple-icons:perplexity', base: 'https://www.perplexity.ai/search?q=', home: 'https://www.perplexity.ai/' },
|
|
57
62
|
];
|
|
58
63
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
const showCopy = options.includes('copy');
|
|
72
|
+
const showView = options.includes('view');
|
|
73
|
+
const hasDropdown = showView || enabledAssistants.length > 0;
|
|
66
74
|
---
|
|
75
|
+
{(showCopy || hasDropdown) && (
|
|
67
76
|
<div class="wd-copy-page-split">
|
|
68
|
-
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
{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">
|
|
79
|
+
<Icon name="lucide:copy" class="wd-copy-page-icon" />
|
|
80
|
+
<span class="wd-copy-page-primary-label">Copy page</span>
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
{hasDropdown && (
|
|
72
84
|
<div class="wd-dropdown wd-copy-page">
|
|
73
|
-
<button type="button" class=
|
|
85
|
+
<button type="button" class:list={['wd-dropdown-trigger', 'wd-copy-page-caret-trigger', { 'wd-copy-page-labeled-trigger': !showCopy }]} aria-haspopup="true" aria-expanded="false" aria-label={showCopy ? 'More copy options' : 'Page options'}>
|
|
86
|
+
{!showCopy && (
|
|
87
|
+
<>
|
|
88
|
+
<Icon name="lucide:sparkles" class="wd-copy-page-icon" />
|
|
89
|
+
<span class="wd-copy-page-primary-label">Page options</span>
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
74
92
|
<svg class="wd-dropdown-caret" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
|
75
93
|
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
|
76
94
|
</svg>
|
|
77
95
|
</button>
|
|
78
96
|
<div class="wd-dropdown-menu wd-copy-page-menu" role="menu">
|
|
79
97
|
<div class="wd-dropdown-menu-panel wd-copy-page-menu-panel">
|
|
98
|
+
{showView && (
|
|
80
99
|
<a class="wd-copy-page-item" href={mdPath} target="_blank" rel="noopener">
|
|
81
100
|
<Icon name="lucide:external-link" class="wd-copy-page-icon" />
|
|
82
101
|
<span class="wd-copy-page-item-text">
|
|
@@ -84,8 +103,9 @@ const enabledAssistants = mdAbsoluteUrl
|
|
|
84
103
|
<span class="wd-copy-page-item-desc">View this page as plain text</span>
|
|
85
104
|
</span>
|
|
86
105
|
</a>
|
|
106
|
+
)}
|
|
87
107
|
{enabledAssistants.map((a) => (
|
|
88
|
-
<a class="wd-copy-page-item" href={a.
|
|
108
|
+
<a class="wd-copy-page-item" href={assistantHref(a)} data-ask-base={mdAbsoluteUrl ? undefined : a.base} data-md-path={mdAbsoluteUrl ? undefined : mdPath} target="_blank" rel="noopener">
|
|
89
109
|
<Icon name={a.icon} class="wd-copy-page-icon" />
|
|
90
110
|
<span class="wd-copy-page-item-text">
|
|
91
111
|
<span class="wd-copy-page-item-title">Open in {a.label}</span>
|
|
@@ -96,7 +116,9 @@ const enabledAssistants = mdAbsoluteUrl
|
|
|
96
116
|
</div>
|
|
97
117
|
</div>
|
|
98
118
|
</div>
|
|
119
|
+
)}
|
|
99
120
|
</div>
|
|
121
|
+
)}
|
|
100
122
|
|
|
101
123
|
<style>
|
|
102
124
|
/* The split button: a primary "Copy page" action (always visible,
|
|
@@ -174,6 +196,20 @@ const enabledAssistants = mdAbsoluteUrl
|
|
|
174
196
|
border-left: 1px solid var(--wd-border);
|
|
175
197
|
border-radius: 0 0.35rem 0.35rem 0;
|
|
176
198
|
}
|
|
199
|
+
/* Only one of the two halves: it gets all four corners (and, for the
|
|
200
|
+
trigger, no divider line and a label like the primary button's). */
|
|
201
|
+
.wd-copy-page-primary.wd-copy-page-alone {
|
|
202
|
+
border-radius: 0.35rem;
|
|
203
|
+
}
|
|
204
|
+
.wd-copy-page-caret-trigger.wd-copy-page-labeled-trigger {
|
|
205
|
+
gap: 0.35rem;
|
|
206
|
+
padding: 0.35rem 0.65rem;
|
|
207
|
+
border-left: none;
|
|
208
|
+
border-radius: 0.35rem;
|
|
209
|
+
color: var(--wd-text-muted);
|
|
210
|
+
font-size: 0.85rem;
|
|
211
|
+
font-weight: 500;
|
|
212
|
+
}
|
|
177
213
|
.wd-copy-page-menu {
|
|
178
214
|
left: auto;
|
|
179
215
|
right: 0;
|
|
@@ -54,9 +54,11 @@ function mapOutsideInlineCode(text, transform) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const PLACEHOLDER = /\[\[\s*([\w.-]+)\s*\]\]/g;
|
|
57
|
-
// A standalone `{key}`
|
|
58
|
-
//
|
|
59
|
-
|
|
57
|
+
// A standalone `{key}` or `{{key}}` expression (Mintlify writes the latter;
|
|
58
|
+
// MDX reads it as `{key}` inside an expression) - not an attribute value
|
|
59
|
+
// (`prop={key}`), which the build leaves alone too. The double form first,
|
|
60
|
+
// or its outer braces would be left around the value.
|
|
61
|
+
const MINTLIFY_PLACEHOLDER = /(?<!=\s*)\{\s*\{\s*([A-Za-z_$][\w$]*)\s*\}\s*\}|(?<!=\s*)\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
|
|
60
62
|
|
|
61
63
|
/** writedocs.json `variables` - `[[key]]`, and Mintlify's `{key}` - replaced
|
|
62
64
|
* the way the build replaces them (lib/mdx-substitute-variables.js): outside
|
|
@@ -67,7 +69,10 @@ export function substituteVariables(text, variables) {
|
|
|
67
69
|
return mapOutsideCode(text, (prose) =>
|
|
68
70
|
prose
|
|
69
71
|
.replace(PLACEHOLDER, (match, key) => (has(key) ? String(variables[key]) : match))
|
|
70
|
-
.replace(MINTLIFY_PLACEHOLDER, (match,
|
|
72
|
+
.replace(MINTLIFY_PLACEHOLDER, (match, doubled, single) => {
|
|
73
|
+
const key = doubled ?? single;
|
|
74
|
+
return has(key) ? String(variables[key]) : match;
|
|
75
|
+
})
|
|
71
76
|
);
|
|
72
77
|
}
|
|
73
78
|
|
package/src/lib/config-schema.js
CHANGED
|
@@ -390,9 +390,26 @@ const apiSchema = z.object({
|
|
|
390
390
|
// site can set this to `false` to never involve a third party.
|
|
391
391
|
proxy: z.boolean().default(true)
|
|
392
392
|
}).strict().default({ proxy: true });
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
393
|
+
const CONTEXT_MENU_OPTIONS = ["copy", "view", "chatgpt", "claude", "perplexity"];
|
|
394
|
+
const ASSISTANT_OPTIONS = ["chatgpt", "claude", "perplexity"];
|
|
395
|
+
const contextMenuSchema = z.union([
|
|
396
|
+
z.boolean(),
|
|
397
|
+
z.array(z.enum(CONTEXT_MENU_OPTIONS)),
|
|
398
|
+
z.object({
|
|
399
|
+
openIn: z.array(z.enum(ASSISTANT_OPTIONS)).optional()
|
|
400
|
+
}).strict()
|
|
401
|
+
]);
|
|
402
|
+
function contextMenuOptions(value) {
|
|
403
|
+
if (value === false) return [];
|
|
404
|
+
if (value === void 0 || value === null || value === true) return [...CONTEXT_MENU_OPTIONS];
|
|
405
|
+
if (Array.isArray(value)) return CONTEXT_MENU_OPTIONS.filter((option) => value.includes(option));
|
|
406
|
+
if (typeof value === "object") {
|
|
407
|
+
const openIn = value.openIn;
|
|
408
|
+
const assistants = Array.isArray(openIn) ? openIn : [...ASSISTANT_OPTIONS];
|
|
409
|
+
return CONTEXT_MENU_OPTIONS.filter((option) => option === "copy" || option === "view" || assistants.includes(option));
|
|
410
|
+
}
|
|
411
|
+
return [];
|
|
412
|
+
}
|
|
396
413
|
const redirectSchema = z.object({
|
|
397
414
|
source: z.string(),
|
|
398
415
|
destination: z.string()
|
|
@@ -519,9 +536,14 @@ const docsConfigSchema = z.object({
|
|
|
519
536
|
// field by field via mergeSeo(), rather than needing to repeat every
|
|
520
537
|
// field on every page.
|
|
521
538
|
seo: seoFieldsSchema.default({}),
|
|
522
|
-
// The "Copy page"
|
|
523
|
-
//
|
|
539
|
+
// The "Copy page" menu - see contextMenuSchema above. Absent means on,
|
|
540
|
+
// with every option; read it through contextMenuOptions().
|
|
524
541
|
contextMenu: contextMenuSchema.optional(),
|
|
542
|
+
// The site's MCP server - an AI tool connects to `/mcp` and searches and
|
|
543
|
+
// reads the docs. On by default: `writedocs build` writes the page index
|
|
544
|
+
// (mcp-index.json) and a Cloudflare-ready `_worker.js` into dist/ (see
|
|
545
|
+
// src/cli/write-mcp-files.js). `false` leaves all of it out.
|
|
546
|
+
mcp: z.boolean().default(true),
|
|
525
547
|
// See redirectSchema above. Wired directly into Astro's own `redirects`
|
|
526
548
|
// config option in astro.config.mjs.
|
|
527
549
|
redirects: z.array(redirectSchema).default([]),
|
|
@@ -824,7 +846,9 @@ function validateDocsConfig(rawText) {
|
|
|
824
846
|
return { ok: true, data: result.data, issues: [] };
|
|
825
847
|
}
|
|
826
848
|
export {
|
|
849
|
+
CONTEXT_MENU_OPTIONS,
|
|
827
850
|
ROOT_ALLOWED_EXTRA_KEYS,
|
|
851
|
+
contextMenuOptions,
|
|
828
852
|
createJsonLocator,
|
|
829
853
|
docsConfigSchema,
|
|
830
854
|
formatValidationIssues,
|
package/src/lib/config-schema.ts
CHANGED
|
@@ -727,25 +727,50 @@ const apiSchema = z
|
|
|
727
727
|
.strict()
|
|
728
728
|
.default({ proxy: true });
|
|
729
729
|
|
|
730
|
-
// The "Copy page"
|
|
731
|
-
// Markdown
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
730
|
+
// The "Copy page" menu shown next to a page's title - copy the page's
|
|
731
|
+
// Markdown, view it, or open it in an AI assistant - and the .md copy of
|
|
732
|
+
// every page it relies on ([...slug].md.ts). On by default, with every
|
|
733
|
+
// option, like the MCP server. writedocs.json narrows or turns it off:
|
|
734
|
+
//
|
|
735
|
+
// (absent) or true every option
|
|
736
|
+
// ["copy", "claude"] only these, in the menu's own order
|
|
737
|
+
// false (or []) no menu, and no .md routes
|
|
738
|
+
// { "openIn": [...] } the earlier form: copy and view, plus these
|
|
739
|
+
// assistants - still accepted
|
|
740
|
+
//
|
|
741
|
+
// Read it through contextMenuOptions() below - never test the raw value for
|
|
742
|
+
// truthiness: absent now means on.
|
|
743
|
+
export const CONTEXT_MENU_OPTIONS = ['copy', 'view', 'chatgpt', 'claude', 'perplexity'] as const;
|
|
744
|
+
export type ContextMenuOption = (typeof CONTEXT_MENU_OPTIONS)[number];
|
|
745
|
+
const ASSISTANT_OPTIONS = ['chatgpt', 'claude', 'perplexity'] as const;
|
|
746
|
+
|
|
747
|
+
const contextMenuSchema = z.union([
|
|
748
|
+
z.boolean(),
|
|
749
|
+
z.array(z.enum(CONTEXT_MENU_OPTIONS)),
|
|
750
|
+
z
|
|
751
|
+
.object({
|
|
752
|
+
openIn: z.array(z.enum(ASSISTANT_OPTIONS)).optional(),
|
|
753
|
+
})
|
|
754
|
+
.strict(),
|
|
755
|
+
]);
|
|
746
756
|
|
|
747
757
|
export type ContextMenuConfig = z.infer<typeof contextMenuSchema>;
|
|
748
758
|
|
|
759
|
+
/** The menu options a writedocs.json `contextMenu` value turns on, in menu
|
|
760
|
+
* order - [] when the menu is off. Takes the raw value too (link-check.js
|
|
761
|
+
* reads writedocs.json without the schema), so absent means every option. */
|
|
762
|
+
export function contextMenuOptions(value: unknown): ContextMenuOption[] {
|
|
763
|
+
if (value === false) return [];
|
|
764
|
+
if (value === undefined || value === null || value === true) return [...CONTEXT_MENU_OPTIONS];
|
|
765
|
+
if (Array.isArray(value)) return CONTEXT_MENU_OPTIONS.filter((option) => value.includes(option));
|
|
766
|
+
if (typeof value === 'object') {
|
|
767
|
+
const openIn = (value as { openIn?: unknown }).openIn;
|
|
768
|
+
const assistants = Array.isArray(openIn) ? openIn : [...ASSISTANT_OPTIONS];
|
|
769
|
+
return CONTEXT_MENU_OPTIONS.filter((option) => option === 'copy' || option === 'view' || assistants.includes(option));
|
|
770
|
+
}
|
|
771
|
+
return [];
|
|
772
|
+
}
|
|
773
|
+
|
|
749
774
|
// One entry in writedocs.json's `redirects` array - wired almost directly into
|
|
750
775
|
// Astro's own `redirects` config option (astro.config.mjs), which is what
|
|
751
776
|
// actually generates the redirect pages. `permanent` is deliberately not
|
|
@@ -996,9 +1021,14 @@ export const docsConfigSchema = z.object({
|
|
|
996
1021
|
// field by field via mergeSeo(), rather than needing to repeat every
|
|
997
1022
|
// field on every page.
|
|
998
1023
|
seo: seoFieldsSchema.default({}),
|
|
999
|
-
// The "Copy page"
|
|
1000
|
-
//
|
|
1024
|
+
// The "Copy page" menu - see contextMenuSchema above. Absent means on,
|
|
1025
|
+
// with every option; read it through contextMenuOptions().
|
|
1001
1026
|
contextMenu: contextMenuSchema.optional(),
|
|
1027
|
+
// The site's MCP server - an AI tool connects to `/mcp` and searches and
|
|
1028
|
+
// reads the docs. On by default: `writedocs build` writes the page index
|
|
1029
|
+
// (mcp-index.json) and a Cloudflare-ready `_worker.js` into dist/ (see
|
|
1030
|
+
// src/cli/write-mcp-files.js). `false` leaves all of it out.
|
|
1031
|
+
mcp: z.boolean().default(true),
|
|
1002
1032
|
// See redirectSchema above. Wired directly into Astro's own `redirects`
|
|
1003
1033
|
// config option in astro.config.mjs.
|
|
1004
1034
|
redirects: z.array(redirectSchema).default([]),
|
package/src/lib/content-check.js
CHANGED
|
@@ -434,6 +434,21 @@ export async function checkContent(contentDir, configText) {
|
|
|
434
434
|
const { message, suggestion } = unknownIconMessage(icon);
|
|
435
435
|
warnings.push(issue('writedocs.json', locate(jsonPath)?.line, message, suggestion));
|
|
436
436
|
}
|
|
437
|
+
// No `domain`: the site builds, but everything that needs its full
|
|
438
|
+
// address goes without - flagged so it's a choice, not a surprise.
|
|
439
|
+
if (typeof config.domain !== 'string' || !config.domain.trim()) {
|
|
440
|
+
// `code` lets `writedocs convert` leave it out - it gives its own
|
|
441
|
+
// domain hint, with where the source tool kept that setting.
|
|
442
|
+
warnings.push({
|
|
443
|
+
code: 'no-domain',
|
|
444
|
+
...issue(
|
|
445
|
+
'writedocs.json',
|
|
446
|
+
undefined,
|
|
447
|
+
'No "domain" set - there\'s no sitemap.xml, 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.',
|
|
448
|
+
'Set "domain" to the site\'s address, like "docs.example.com".'
|
|
449
|
+
),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
437
452
|
}
|
|
438
453
|
await checkOpenApi(contentDir, config, config ? createJsonLocator(configText) : null, openapiRefs, errors, warnings);
|
|
439
454
|
|
|
@@ -164,8 +164,9 @@ export const DESCRIPTIONS = {
|
|
|
164
164
|
'seo.twitterCard': 'X/Twitter card style. Default "summary_large_image" with an `ogImage`, "summary" without.',
|
|
165
165
|
'seo.keywords': 'Keywords for the <meta name="keywords"> tag.',
|
|
166
166
|
'seo.noindex': 'Ask search engines not to index pages, and leave them out of sitemap.xml.',
|
|
167
|
-
contextMenu: '
|
|
168
|
-
'contextMenu.openIn': '
|
|
167
|
+
contextMenu: 'The "Copy page" menu on every page - copy as Markdown, view as Markdown, open in ChatGPT, Claude or Perplexity - and a Markdown copy of each page at its address + ".md". On by default with every option. A list picks the options ("copy", "view", "chatgpt", "claude", "perplexity"); false turns it all off.',
|
|
168
|
+
'contextMenu.openIn': 'The earlier form: which AI assistants the menu offers, besides copy and view. A list of options replaces it.',
|
|
169
|
+
mcp: 'An MCP server at /mcp, so AI tools can search and read the docs. The build adds its index and a Cloudflare-ready _worker.js to dist/. Default true; false leaves them out.',
|
|
169
170
|
redirects: 'Redirects from old addresses. Each matches one exact path.',
|
|
170
171
|
'redirects[].source': 'The old path, like "/old-page".',
|
|
171
172
|
'redirects[].destination': 'Where to send it, like "/docs/new-page/".',
|