@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 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('info', 'No "domain" set in writedocs.json, so no sitemap.xml was generated.');
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.7.4",
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 {
@@ -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 - opt-in via writedocs.json's `contextMenu`
3
- // field (see contextMenuSchema in lib/config.ts). A primary button
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 - siteUrl is null on sites with no
23
- // writedocs.json `domain` set, in which case those three are left out
24
- // entirely rather than linking a service at a URL it could never fetch).
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 { ContextMenuConfig } from '../lib/config';
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
- contextMenu: ContextMenuConfig;
34
+ options: ContextMenuOption[];
32
35
  }
33
36
 
34
- const { currentPath, siteUrl, contextMenu } = Astro.props as Props;
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', buildHref: (q: string) => `https://chatgpt.com/?q=${q}` },
55
- { id: 'claude' as const, label: 'Claude', icon: 'simple-icons:claude', buildHref: (q: string) => `https://claude.ai/new?q=${q}` },
56
- { id: 'perplexity' as const, label: 'Perplexity', icon: 'simple-icons:perplexity', buildHref: (q: string) => `https://www.perplexity.ai/search?q=${q}` },
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
- // No siteUrl at all -> mdAbsoluteUrl is null -> none of these render,
60
- // regardless of what contextMenu.openIn lists: a link these services
61
- // could never actually fetch (a bare relative path, meaningless once
62
- // it's opened on a different origin) is worse than not offering it.
63
- const enabledAssistants = mdAbsoluteUrl
64
- ? assistants.filter((a) => contextMenu.openIn.includes(a.id))
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
- <button type="button" class="wd-copy-page-primary" data-md-path={mdPath} aria-label="Copy page as Markdown">
69
- <Icon name="lucide:copy" class="wd-copy-page-icon" />
70
- <span class="wd-copy-page-primary-label">Copy page</span>
71
- </button>
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="wd-dropdown-trigger wd-copy-page-caret-trigger" aria-haspopup="true" aria-expanded="false" aria-label="More copy options">
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.buildHref(encodeURIComponent(promptFor(mdAbsoluteUrl!)))} target="_blank" rel="noopener">
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}` expression - not an attribute value (`prop={key}`),
58
- // which the build leaves alone too.
59
- const MINTLIFY_PLACEHOLDER = /(?<!=\s*)\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
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, key) => (has(key) ? String(variables[key]) : 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
 
@@ -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 contextMenuSchema = z.object({
394
- openIn: z.array(z.enum(["chatgpt", "claude", "perplexity"])).default(["chatgpt", "claude", "perplexity"])
395
- }).strict();
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" dropdown - see contextMenuSchema above. Absent by
523
- // default (no menu, no .md routes).
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,
@@ -727,25 +727,50 @@ const apiSchema = z
727
727
  .strict()
728
728
  .default({ proxy: true });
729
729
 
730
- // The "Copy page" dropdown shown next to a page's title - copy the raw
731
- // Markdown to the clipboard, open the raw Markdown in a new tab, or
732
- // deep-link into an AI assistant with a prompt pointing at it. Opt-in:
733
- // undefined (the field simply absent from writedocs.json) means the feature
734
- // is off entirely - no menu rendered, no .md routes generated at build
735
- // time either (see [...slug].md.ts) - rather than defaulting to on,
736
- // since it changes both the UI and the build output. Once a site does
737
- // write a `contextMenu` key (even `{}`), copying/viewing-as-Markdown are
738
- // always included (they need nothing but the page's own content); only
739
- // `openIn` (the AI-assistant deep-links, which need an absolute URL to
740
- // point the assistant at) is independently configurable.
741
- const contextMenuSchema = z
742
- .object({
743
- openIn: z.array(z.enum(['chatgpt', 'claude', 'perplexity'])).default(['chatgpt', 'claude', 'perplexity']),
744
- })
745
- .strict();
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" dropdown - see contextMenuSchema above. Absent by
1000
- // default (no menu, no .md routes).
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([]),
@@ -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: 'Adds a "Copy page" menu to pages - copy as Markdown, or open the page in an AI assistant - and a Markdown copy of each page at its address + ".md".',
168
- 'contextMenu.openIn': 'Which AI assistants the menu offers. Default: all three.',
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/".',