@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 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('info', 'No "domain" set in writedocs.json, so no sitemap.xml was generated.');
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
- const packageRoot = path.resolve(__dirname, '..');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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": {
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
+ }
@@ -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
+ }
@@ -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" role="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 - 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,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 - 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 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 { ContextMenuConfig } from '../lib/config';
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
- contextMenu: ContextMenuConfig;
41
+ siteName: string;
42
+ options: ContextMenuOption[];
32
43
  }
33
44
 
34
- const { currentPath, siteUrl, contextMenu } = Astro.props as Props;
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', 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}` },
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
- // 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
- : [];
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
- <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>
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="wd-dropdown-trigger wd-copy-page-caret-trigger" aria-haspopup="true" aria-expanded="false" aria-label="More copy options">
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" role="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
- <a class="wd-copy-page-item" href={mdPath} target="_blank" rel="noopener">
81
- <Icon name="lucide:external-link" class="wd-copy-page-icon" />
82
- <span class="wd-copy-page-item-text">
83
- <span class="wd-copy-page-item-title">View as Markdown</span>
84
- <span class="wd-copy-page-item-desc">View this page as plain text</span>
85
- </span>
86
- </a>
87
- {enabledAssistants.map((a) => (
88
- <a class="wd-copy-page-item" href={a.buildHref(encodeURIComponent(promptFor(mdAbsoluteUrl!)))} target="_blank" rel="noopener">
89
- <Icon name={a.icon} class="wd-copy-page-icon" />
90
- <span class="wd-copy-page-item-text">
91
- <span class="wd-copy-page-item-title">Open in {a.label}</span>
92
- <span class="wd-copy-page-item-desc">Ask questions about this page</span>
93
- </span>
94
- </a>
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} />}