@writedocs/generator 0.8.0 → 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
@@ -107,7 +107,10 @@ const contentPublicDir = path.join(contentDir, 'public');
107
107
  // document-relative URLs isn't meaningful anyway.
108
108
  const siteUrl = resolveSiteUrl(docsConfig);
109
109
  if (!siteUrl) {
110
- 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
+ );
111
114
  }
112
115
 
113
116
  /** Recursively lists every .md/.mdx file under `baseDir` (Node 20's
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.8.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": {
@@ -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
+ }
@@ -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;
@@ -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,8 +536,8 @@ 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(),
525
542
  // The site's MCP server - an AI tool connects to `/mcp` and searches and
526
543
  // reads the docs. On by default: `writedocs build` writes the page index
@@ -829,7 +846,9 @@ function validateDocsConfig(rawText) {
829
846
  return { ok: true, data: result.data, issues: [] };
830
847
  }
831
848
  export {
849
+ CONTEXT_MENU_OPTIONS,
832
850
  ROOT_ALLOWED_EXTRA_KEYS,
851
+ contextMenuOptions,
833
852
  createJsonLocator,
834
853
  docsConfigSchema,
835
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,8 +1021,8 @@ 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(),
1002
1027
  // The site's MCP server - an AI tool connects to `/mcp` and searches and
1003
1028
  // reads the docs. On by default: `writedocs build` writes the page index
@@ -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,8 @@ 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
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.',
170
170
  redirects: 'Redirects from old addresses. Each matches one exact path.',
171
171
  'redirects[].source': 'The old path, like "/old-page".',
@@ -33,7 +33,7 @@ import { pathToFileURL } from 'node:url';
33
33
  import matter from 'gray-matter';
34
34
  import { visit } from 'unist-util-visit';
35
35
  import { findAllPages } from './pages.js';
36
- import { createJsonLocator } from './config-schema.js';
36
+ import { createJsonLocator, contextMenuOptions } from './config-schema.js';
37
37
  import { writedocsTempDir } from './writedocs-temp-dir.js';
38
38
 
39
39
  // The same github-slugger Astro builds page URLs and heading ids with -
@@ -423,7 +423,7 @@ export async function checkLinks(contentDir, configText) {
423
423
  // A link to a page's source file (docs/setup.mdx) instead of its URL.
424
424
  if (extension === '.mdx' || extension === '.md') {
425
425
  const bare = pathname.replace(/\.mdx?$/i, '').replace(/\/?$/, '/');
426
- if (extension === '.md' && config.contextMenu && pages.has(bare)) return; // the page's Markdown copy
426
+ if (extension === '.md' && contextMenuOptions(config.contextMenu).length && pages.has(bare)) return; // the page's Markdown copy (on unless "contextMenu": false)
427
427
  // Written as a file path, so look it up as one: from the file's own
428
428
  // folder when relative.
429
429
  const filePath = isRelative && fileAbs ? path.resolve(path.dirname(fileAbs), decodeURI(value.split(/[?#]/)[0])) : path.join(contentDir, pathname);
@@ -6,7 +6,7 @@
6
6
  import { getCollection, type CollectionEntry } from 'astro:content';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl, fileIdForEntry } from './config';
9
+ import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl, fileIdForEntry, contextMenuOptions } from './config';
10
10
  import { buildLlmsTree, renderLlmsFiles } from './llms.js';
11
11
  import { navigationPageOrder } from './pages.js';
12
12
  import { writedocsTempDir } from './writedocs-temp-dir.js';
@@ -59,10 +59,10 @@ export async function llmsIndexFiles(): Promise<Map<string, string> | null> {
59
59
  // page (Mintlify's external link) has no content of its own.
60
60
  if (entry.data.seo?.noindex || entry.data.url) continue;
61
61
  const slug = normalizeEntryId(entry.id);
62
- // The .md route when it exists ([...slug].md.ts - with `contextMenu`,
62
+ // The .md route when it exists ([...slug].md.ts - unless `contextMenu` is off,
63
63
  // and never for an OpenAPI page, which renders from the spec), else the
64
64
  // HTML page.
65
- const hasMarkdownRoute = config.contextMenu && !entry.data.openapi;
65
+ const hasMarkdownRoute = contextMenuOptions(config.contextMenu).length > 0 && !entry.data.openapi;
66
66
  const href = (siteUrl ?? '') + (hasMarkdownRoute ? `/${slug}.md` : slug === 'index' ? '/' : `/${slug}/`);
67
67
  let description = truncateDescription(entry.data.description);
68
68
  // Mirrors Mintlify: an OpenAPI operation page's description gets its
@@ -530,13 +530,16 @@ export function convertMintlifyConfig(docs) {
530
530
  if (docs.seo?.indexing === 'all') notes.add('seo.indexing', ['seo', 'indexing'], '`seo.indexing: "all"` has no equivalent - writedocs indexes every page that isn\'t marked noindex.');
531
531
 
532
532
  // context menu
533
+ // Mintlify's option names are writedocs' own for the five both have, so the
534
+ // list carries over as is. No `contextual` leaves the field out - the menu
535
+ // is on by default, with every option.
533
536
  const options = docs.contextual?.options;
534
537
  if (Array.isArray(options) && options.length) {
535
- const openIn = options.filter((o) => ['chatgpt', 'claude', 'perplexity'].includes(o));
536
- out.contextMenu = { openIn };
537
- const other = options.filter((o) => typeof o !== 'string' || !['copy', 'view', 'chatgpt', 'claude', 'perplexity'].includes(o));
538
+ const known = ['copy', 'view', 'chatgpt', 'claude', 'perplexity'];
539
+ out.contextMenu = known.filter((o) => options.includes(o));
540
+ const other = options.filter((o) => typeof o !== 'string' || !known.includes(o));
538
541
  if (other.length) {
539
- notes.add('contextual', ['contextual', 'options'], `Context menu options writedocs doesn't have were dropped: ${other.map((o) => (typeof o === 'string' ? o : o.title ?? 'custom')).join(', ')}.`, 'writedocs\' page menu always has copy and view-as-Markdown, plus open in ChatGPT, Claude and Perplexity.');
542
+ notes.add('contextual', ['contextual', 'options'], `Context menu options writedocs doesn't have were dropped: ${other.map((o) => (typeof o === 'string' ? o : o.title ?? 'custom')).join(', ')}.`, 'writedocs\' page menu offers copy, view as Markdown, and open in ChatGPT, Claude and Perplexity.');
540
543
  }
541
544
  }
542
545
 
@@ -16,6 +16,7 @@ import {
16
16
  flattenNav,
17
17
  firstSlugOfNavigation,
18
18
  mergeSeo,
19
+ contextMenuOptions,
19
20
  resolveSiteUrl,
20
21
  findAllPages,
21
22
  } from "../lib/config";
@@ -414,8 +415,9 @@ const showToc = pageMode === "default";
414
415
  // branch on separately.
415
416
  const isCanvasMode = pageMode === "custom" || pageMode === "blank";
416
417
 
417
- // The "Copy page" dropdown (CopyPageMenu.astro) - opt-in via writedocs.json's
418
- // `contextMenu` field (see contextMenuSchema in lib/config.ts), and only
418
+ // The "Copy page" menu (CopyPageMenu.astro) - on by default, with the
419
+ // options writedocs.json's `contextMenu` leaves on (contextMenuOptions() in
420
+ // lib/config-schema.ts; `false` turns it off), and only
419
421
  // shown where there's a real auto-rendered <h1> to sit next to: canvas
420
422
  // mode pages (custom/blank) have no such header (a hand-built landing
421
423
  // page controls its own layout, there's nothing standard to anchor the
@@ -423,7 +425,8 @@ const isCanvasMode = pageMode === "custom" || pageMode === "blank";
423
425
  // spec rather than prose - see [...slug].md.ts's own comment on why
424
426
  // those are excluded from the .md route this menu links to in the first
425
427
  // place, which this mirrors on the UI side.
426
- const showCopyPageMenu = Boolean(config.contextMenu) && !isCanvasMode && !entry.data.openapi;
428
+ const contextMenuItems = contextMenuOptions(config.contextMenu);
429
+ const showCopyPageMenu = contextMenuItems.length > 0 && !isCanvasMode && !entry.data.openapi;
427
430
  const siteUrl = resolveSiteUrl(config);
428
431
 
429
432
  const components = {
@@ -513,7 +516,7 @@ const components = {
513
516
  )
514
517
  }
515
518
  {showCopyPageMenu && (
516
- <CopyPageMenu currentPath={currentPath} siteUrl={siteUrl} contextMenu={config.contextMenu!} />
519
+ <CopyPageMenu currentPath={currentPath} siteUrl={siteUrl} options={contextMenuItems} />
517
520
  )}
518
521
  </div>
519
522
  <Content components={components} />
@@ -672,6 +675,19 @@ const components = {
672
675
  initCopyPageMenu(document);
673
676
  document.addEventListener("astro:page-load", () => initCopyPageMenu(document));
674
677
 
678
+ // "Open in ChatGPT/Claude/Perplexity" on a site with no writedocs.json
679
+ // `domain`: the page's absolute .md URL isn't known at build time, so the
680
+ // link is completed here from the address the page is served from (see
681
+ // CopyPageMenu.astro) - same prompt the build writes when it does know.
682
+ function initAskLinks(root: ParentNode) {
683
+ root.querySelectorAll<HTMLAnchorElement>("a[data-ask-base][data-md-path]").forEach((link) => {
684
+ const url = new URL(link.dataset.mdPath ?? "/", window.location.origin).href;
685
+ link.href = link.dataset.askBase + encodeURIComponent(`Read ${url} so you can answer questions about it.`);
686
+ });
687
+ }
688
+ initAskLinks(document);
689
+ document.addEventListener("astro:page-load", () => initAskLinks(document));
690
+
675
691
  // Show more/less toggle for ```js expandable code blocks - same
676
692
  // build-time-emitted-button + client-wired-click pattern as the copy
677
693
  // button above; codeBlockTransformer only adds this button when the
@@ -845,7 +861,7 @@ const components = {
845
861
  width: 100%;
846
862
  }
847
863
  /* Wraps the auto-rendered <h1> together with CopyPageMenu (only
848
- rendered when writedocs.json's `contextMenu` is set - see
864
+ rendered when writedocs.json's `contextMenu` isn't off - see
849
865
  showCopyPageMenu above) so the two sit on one row, menu pinned to
850
866
  the right. Always present (even with no menu) rather than only
851
867
  wrapping the <h1> conditionally, so the <h1>'s own top/bottom
@@ -2,7 +2,7 @@ import { getCollection, type CollectionEntry } from 'astro:content';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import type { APIRoute } from 'astro';
5
- import { loadDocsConfig, normalizeEntryId, findAllPages } from '../lib/config';
5
+ import { loadDocsConfig, normalizeEntryId, findAllPages, contextMenuOptions } from '../lib/config';
6
6
  import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
7
7
  import { markdownForAgents } from '../lib/agent-markdown.js';
8
8
 
@@ -11,10 +11,10 @@ import { markdownForAgents } from '../lib/agent-markdown.js';
11
11
  // /guides/foo/ -> /guides/foo.md), serving its untouched MDX/Markdown
12
12
  // source instead of rendered HTML - what the "Copy page"/"View as
13
13
  // Markdown" menu (CopyPageMenu.astro, wired in from [...slug].astro)
14
- // links to, and what an LLM fetching that URL directly gets. Gated
15
- // entirely behind writedocs.json's `contextMenu` field being present (see
16
- // contextMenuSchema in lib/config.ts) - a site that hasn't opted in gets
17
- // no .md routes at all, not just a hidden menu.
14
+ // links to, and what an LLM fetching that URL directly gets. On by
15
+ // default, like the menu; writedocs.json `"contextMenu": false` (see
16
+ // contextMenuOptions() in lib/config-schema.ts) leaves out the .md routes
17
+ // too, not just the menu.
18
18
  //
19
19
  // A literal ".md" filename suffix rather than a `[...slug]` capture
20
20
  // covering it: naming the file `[...slug].md.ts` makes Astro treat ".md"
@@ -33,9 +33,9 @@ type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
33
33
  export async function getStaticPaths() {
34
34
  const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
35
35
  const config = loadDocsConfig(contentDir);
36
- // No writedocs.json `contextMenu` - feature is off entirely, including these
37
- // routes (not just the menu UI - see the file-level comment above).
38
- if (!config.contextMenu) return [];
36
+ // `"contextMenu": false` - the feature is off entirely, these routes
37
+ // included (not just the menu UI - see the file-level comment above).
38
+ if (contextMenuOptions(config.contextMenu).length === 0) return [];
39
39
 
40
40
  const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
41
41
  const hasPages = findAllPages(contentDir).length > 0;
@@ -747,30 +747,45 @@
747
747
  "markdownDescription": "Default metadata for every page. A page's own `seo` frontmatter overrides it field by field."
748
748
  },
749
749
  "contextMenu": {
750
- "type": "object",
751
- "properties": {
752
- "openIn": {
753
- "default": [
754
- "chatgpt",
755
- "claude",
756
- "perplexity"
757
- ],
750
+ "anyOf": [
751
+ {
752
+ "type": "boolean"
753
+ },
754
+ {
758
755
  "type": "array",
759
756
  "items": {
760
757
  "type": "string",
761
758
  "enum": [
759
+ "copy",
760
+ "view",
762
761
  "chatgpt",
763
762
  "claude",
764
763
  "perplexity"
765
764
  ]
765
+ }
766
+ },
767
+ {
768
+ "type": "object",
769
+ "properties": {
770
+ "openIn": {
771
+ "type": "array",
772
+ "items": {
773
+ "type": "string",
774
+ "enum": [
775
+ "chatgpt",
776
+ "claude",
777
+ "perplexity"
778
+ ]
779
+ },
780
+ "description": "The earlier form: which AI assistants the menu offers, besides copy and view. A list of options replaces it.",
781
+ "markdownDescription": "The earlier form: which AI assistants the menu offers, besides copy and view. A list of options replaces it."
782
+ }
766
783
  },
767
- "description": "Which AI assistants the menu offers. Default: all three.",
768
- "markdownDescription": "Which AI assistants the menu offers. Default: all three."
784
+ "additionalProperties": false
769
785
  }
770
- },
771
- "additionalProperties": false,
772
- "description": "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\".",
773
- "markdownDescription": "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\"."
786
+ ],
787
+ "description": "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.",
788
+ "markdownDescription": "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."
774
789
  },
775
790
  "mcp": {
776
791
  "default": true,