@writedocs/generator 0.8.1 → 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,
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
@@ -64,7 +67,7 @@ program
64
67
  .option('--no-open', "don't open the preview in the browser")
65
68
  .action(async (dir, opts) => {
66
69
  await runDev({
67
- contentDir: path.resolve(process.cwd(), dir),
70
+ contentDir: canonicalPath(path.resolve(process.cwd(), dir)),
68
71
  packageRoot,
69
72
  port: opts.port,
70
73
  verbose: Boolean(opts.verbose),
@@ -86,7 +89,7 @@ program
86
89
  .option('-k, --key <key>', 'build authorization key (or set WRITEDOCS_API_KEY)')
87
90
  .option('--verbose', "also print Astro's and Vite's own output")
88
91
  .action(async (dir, opts) => {
89
- const contentDir = path.resolve(process.cwd(), dir);
92
+ const contentDir = canonicalPath(path.resolve(process.cwd(), dir));
90
93
  // Project-scoped, not global: a .env sitting next to this project's own
91
94
  // writedocs.json (WRITEDOCS_API_KEY) is picked up automatically, so
92
95
  // build doesn't need it exported by hand every
@@ -106,7 +109,7 @@ program
106
109
  .argument('[dir]', 'content directory (contains writedocs.json)', '.')
107
110
  .option('--config-only', 'check writedocs.json only, not the pages')
108
111
  .action(async (dir, options) => {
109
- const contentDir = path.resolve(process.cwd(), dir);
112
+ const contentDir = canonicalPath(path.resolve(process.cwd(), dir));
110
113
  const configPath = path.join(contentDir, 'writedocs.json');
111
114
  if (!fs.existsSync(configPath)) {
112
115
  // Mesma mensagem que loadDocsConfig() ja da pro mesmo caso.
@@ -192,7 +195,7 @@ program
192
195
  .description('Check every internal link - pages, anchors and files - in the pages and writedocs.json')
193
196
  .argument('[dir]', 'content directory (contains writedocs.json)', '.')
194
197
  .action(async (dir) => {
195
- const contentDir = path.resolve(process.cwd(), dir);
198
+ const contentDir = canonicalPath(path.resolve(process.cwd(), dir));
196
199
  const { preflightCheck } = await import('../src/cli/preflight.js');
197
200
  preflightCheck(contentDir);
198
201
  const configText = readConfigText(contentDir);
@@ -227,7 +230,7 @@ program
227
230
  .description('Check accessibility - color contrast, image alt text, headings, link text')
228
231
  .argument('[dir]', 'content directory (contains writedocs.json)', '.')
229
232
  .action(async (dir) => {
230
- const contentDir = path.resolve(process.cwd(), dir);
233
+ const contentDir = canonicalPath(path.resolve(process.cwd(), dir));
231
234
  const { preflightCheck } = await import('../src/cli/preflight.js');
232
235
  preflightCheck(contentDir);
233
236
  const configText = readConfigText(contentDir);
@@ -276,7 +279,7 @@ program
276
279
  const { runConvert } = await import('../src/cli/convert.js');
277
280
  await runConvert({
278
281
  source: legacy ? 'writedocs' : 'mintlify',
279
- contentDir: path.resolve(process.cwd(), dir),
282
+ contentDir: canonicalPath(path.resolve(process.cwd(), dir)),
280
283
  force: Boolean(options.force),
281
284
  dryRun: Boolean(options.dryRun),
282
285
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.8.1",
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
+ }
package/src/cli/dev.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { canonicalPath } from '../lib/canonical-path.js';
1
2
  import { runAstro } from './run-astro.js';
2
3
  import { preflightCheck, sameDriveCheck, writableInstallCheck } from './preflight.js';
3
4
  import { generateApiPages } from './generate-api-pages.js';
@@ -14,6 +15,9 @@ import { shouldOpenBrowser, openBrowser } from './open-browser.js';
14
15
  const REPEAT_WINDOW_MS = 2000;
15
16
 
16
17
  export async function runDev({ contentDir, packageRoot, port, verbose = false, open = true }) {
18
+ // One spelling of each folder throughout - see lib/canonical-path.js.
19
+ contentDir = canonicalPath(contentDir);
20
+ packageRoot = canonicalPath(packageRoot);
17
21
  const started = Date.now();
18
22
  preflightCheck(contentDir);
19
23
  writableInstallCheck(packageRoot);
@@ -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
@@ -23,18 +23,26 @@
23
23
  // (which need an absolute URL - from writedocs.json's `domain` when it's
24
24
  // set, else from the page's own address in the browser; see
25
25
  // enabledAssistants below).
26
- // Without `copy`, the dropdown gets a labeled trigger of its own; with only
27
- // `copy`, the primary button stands alone.
26
+ // Without `copy`, the options get a labeled "Page options" trigger of their
27
+ // own - or, when there's just one, that option is the button itself ("Open
28
+ // in Claude"). With only `copy`, the primary button stands alone.
29
+ //
30
+ // The last three options connect AI tools to the site's MCP server (/mcp -
31
+ // see docs/dev/docs/mcp.mdx): copy its address, or add it to Cursor or VS
32
+ // Code through their install links (lib/mcp-links.js). [...slug].astro
33
+ // leaves them out when writedocs.json turns `mcp` off.
28
34
  import { Icon } from 'astro-icon/components';
29
35
  import type { ContextMenuOption } from '../lib/config';
36
+ import { mcpServerUrl, cursorInstallLink, vscodeInstallLink } from '../lib/mcp-links.js';
30
37
 
31
38
  interface Props {
32
39
  currentPath: string; // e.g. "/" or "/guides/foo/" - see hrefForSlug() in [...slug].astro
33
40
  siteUrl: string | null;
41
+ siteName: string;
34
42
  options: ContextMenuOption[];
35
43
  }
36
44
 
37
- const { currentPath, siteUrl, options } = Astro.props as Props;
45
+ const { currentPath, siteUrl, siteName, options } = Astro.props as Props;
38
46
 
39
47
  // The [...slug].md.ts route this page is also served at - same
40
48
  // normalizeEntryId()-driven convention that file uses to build its own
@@ -42,6 +50,7 @@ const { currentPath, siteUrl, options } = Astro.props as Props;
42
50
  // slug with a literal ".md" appended instead of the trailing slash).
43
51
  const mdPath = currentPath === '/' ? '/index.md' : `${currentPath.replace(/\/$/, '')}.md`;
44
52
  const mdAbsoluteUrl = siteUrl ? `${siteUrl}${mdPath}` : null;
53
+ const mcpUrl = siteUrl ? mcpServerUrl(siteUrl) : null;
45
54
 
46
55
  // Well-known query-string conventions for starting a new conversation
47
56
  // pre-seeded with a prompt (not an official API for any of the three -
@@ -60,29 +69,85 @@ const assistants = [
60
69
  { id: 'claude' as const, label: 'Claude', icon: 'simple-icons:claude', base: 'https://claude.ai/new?q=', home: 'https://claude.ai/new' },
61
70
  { id: 'perplexity' as const, label: 'Perplexity', icon: 'simple-icons:perplexity', base: 'https://www.perplexity.ai/search?q=', home: 'https://www.perplexity.ai/' },
62
71
  ];
72
+ const editors = [
73
+ { id: 'cursor' as const, label: 'Cursor', icon: 'simple-icons:cursor', link: cursorInstallLink, home: 'https://docs.cursor.com/context/mcp' },
74
+ { id: 'vscode' as const, label: 'VS Code', icon: 'simple-icons:visualstudiocode', link: vscodeInstallLink, home: 'https://code.visualstudio.com/docs/copilot/chat/mcp-servers' },
75
+ ];
76
+
77
+ // Every link needs an absolute URL - the page's .md for an assistant, the
78
+ // MCP server for an editor. With a `domain` it's known at build time and the
79
+ // link is complete in the HTML. Without one, the page script ([...slug].astro)
80
+ // completes it from the address the page is actually served from, using the
81
+ // data-* attributes below; until then the link goes to the tool's own page.
82
+ type Item = {
83
+ id: ContextMenuOption;
84
+ title: string;
85
+ desc: string;
86
+ icon: string;
87
+ group: 'page' | 'mcp';
88
+ href?: string;
89
+ newTab?: boolean;
90
+ attrs?: Record<string, string | undefined>;
91
+ };
92
+ const items: Item[] = [];
93
+ if (options.includes('view')) {
94
+ items.push({ id: 'view', title: 'View as Markdown', desc: 'View this page as plain text', icon: 'lucide:external-link', group: 'page', href: mdPath, newTab: true });
95
+ }
96
+ for (const a of assistants.filter((a) => options.includes(a.id))) {
97
+ items.push({
98
+ id: a.id,
99
+ title: `Open in ${a.label}`,
100
+ desc: 'Ask questions about this page',
101
+ icon: a.icon,
102
+ group: 'page',
103
+ href: mdAbsoluteUrl ? a.base + encodeURIComponent(promptFor(mdAbsoluteUrl)) : a.home,
104
+ newTab: true,
105
+ attrs: mdAbsoluteUrl ? undefined : { 'data-ask-base': a.base, 'data-md-path': mdPath },
106
+ });
107
+ }
108
+ if (options.includes('mcp')) {
109
+ items.push({ id: 'mcp', title: 'Copy MCP server URL', desc: 'Connect AI tools to these docs', icon: 'simple-icons:modelcontextprotocol', group: 'mcp', attrs: { 'data-copy-mcp': mcpUrl ?? '' } });
110
+ }
111
+ for (const e of editors.filter((e) => options.includes(e.id))) {
112
+ items.push({
113
+ id: e.id,
114
+ title: `Connect to ${e.label}`,
115
+ desc: `Use these docs in ${e.label}`,
116
+ icon: e.icon,
117
+ group: 'mcp',
118
+ // An editor's own link scheme (cursor://, vscode:) - opens the app, not a tab.
119
+ href: mcpUrl ? e.link(siteName, mcpUrl) : e.home,
120
+ attrs: mcpUrl ? undefined : { 'data-mcp-install': e.id, 'data-mcp-name': siteName },
121
+ });
122
+ }
63
123
 
64
- // The assistant needs the page's absolute URL to fetch it. With a `domain`
65
- // it's known at build time and the link is complete in the HTML. Without
66
- // one, the page script (initCopyPageMenu() in [...slug].astro) completes it
67
- // from the address the page is actually served from - `data-ask-base` +
68
- // the prompt for that URL, the same prompt promptFor() writes.
69
- const enabledAssistants = assistants.filter((a) => options.includes(a.id));
70
- const assistantHref = (a: (typeof assistants)[number]) => (mdAbsoluteUrl ? a.base + encodeURIComponent(promptFor(mdAbsoluteUrl)) : a.home);
71
124
  const showCopy = options.includes('copy');
72
- const showView = options.includes('view');
73
- const hasDropdown = showView || enabledAssistants.length > 0;
125
+ // One option and no "Copy page": that option is the button.
126
+ const single = !showCopy && items.length === 1 ? items[0] : null;
127
+ const hasDropdown = items.length > 0 && !single;
74
128
  ---
75
- {(showCopy || hasDropdown) && (
76
- <div class="wd-copy-page-split">
129
+ {(showCopy || items.length > 0) && (
130
+ <div class="wd-copy-page-split" data-pagefind-ignore="all">
77
131
  {showCopy && (
78
- <button type="button" class:list={['wd-copy-page-primary', { 'wd-copy-page-alone': !hasDropdown }]} data-md-path={mdPath} aria-label="Copy page as Markdown">
132
+ <button type="button" class:list={['wd-copy-page-primary', { 'wd-copy-page-alone': !hasDropdown }]} data-copy-page data-md-path={mdPath} aria-label="Copy page as Markdown">
79
133
  <Icon name="lucide:copy" class="wd-copy-page-icon" />
80
134
  <span class="wd-copy-page-primary-label">Copy page</span>
81
135
  </button>
82
136
  )}
137
+ {single && (single.href ? (
138
+ <a class="wd-copy-page-primary wd-copy-page-alone" href={single.href} target={single.newTab ? '_blank' : undefined} rel={single.newTab ? 'noopener' : undefined} {...single.attrs}>
139
+ <Icon name={single.icon} class="wd-copy-page-icon" />
140
+ <span class="wd-copy-page-primary-label">{single.title}</span>
141
+ </a>
142
+ ) : (
143
+ <button type="button" class="wd-copy-page-primary wd-copy-page-alone" {...single.attrs}>
144
+ <Icon name={single.icon} class="wd-copy-page-icon" />
145
+ <span class="wd-copy-page-primary-label">{single.title}</span>
146
+ </button>
147
+ ))}
83
148
  {hasDropdown && (
84
149
  <div class="wd-dropdown wd-copy-page">
85
- <button type="button" class:list={['wd-dropdown-trigger', 'wd-copy-page-caret-trigger', { 'wd-copy-page-labeled-trigger': !showCopy }]} aria-haspopup="true" aria-expanded="false" aria-label={showCopy ? 'More copy options' : 'Page 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'}>
86
151
  {!showCopy && (
87
152
  <>
88
153
  <Icon name="lucide:sparkles" class="wd-copy-page-icon" />
@@ -93,25 +158,29 @@ const hasDropdown = showView || enabledAssistants.length > 0;
93
158
  <path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" stroke-linejoin="round" />
94
159
  </svg>
95
160
  </button>
96
- <div class="wd-dropdown-menu wd-copy-page-menu" role="menu">
161
+ <div class="wd-dropdown-menu wd-copy-page-menu">
97
162
  <div class="wd-dropdown-menu-panel wd-copy-page-menu-panel">
98
- {showView && (
99
- <a class="wd-copy-page-item" href={mdPath} target="_blank" rel="noopener">
100
- <Icon name="lucide:external-link" class="wd-copy-page-icon" />
101
- <span class="wd-copy-page-item-text">
102
- <span class="wd-copy-page-item-title">View as Markdown</span>
103
- <span class="wd-copy-page-item-desc">View this page as plain text</span>
104
- </span>
105
- </a>
106
- )}
107
- {enabledAssistants.map((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">
109
- <Icon name={a.icon} class="wd-copy-page-icon" />
110
- <span class="wd-copy-page-item-text">
111
- <span class="wd-copy-page-item-title">Open in {a.label}</span>
112
- <span class="wd-copy-page-item-desc">Ask questions about this page</span>
113
- </span>
114
- </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
+ </>
115
184
  ))}
116
185
  </div>
117
186
  </div>
@@ -201,6 +270,16 @@ const hasDropdown = showView || enabledAssistants.length > 0;
201
270
  .wd-copy-page-primary.wd-copy-page-alone {
202
271
  border-radius: 0.35rem;
203
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
+ }
204
283
  .wd-copy-page-caret-trigger.wd-copy-page-labeled-trigger {
205
284
  gap: 0.35rem;
206
285
  padding: 0.35rem 0.65rem;
@@ -259,6 +338,12 @@ const hasDropdown = showView || enabledAssistants.length > 0;
259
338
  text-align: left;
260
339
  cursor: pointer;
261
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
+ }
262
347
  .wd-copy-page-item:hover {
263
348
  background: var(--wd-surface);
264
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} />}