@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.
@@ -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;
@@ -4,56 +4,91 @@
4
4
  // written for the topbar's own switcher/tab dropdowns, but the same
5
5
  // `.wd-dropdown`/`.wd-dropdown-trigger`/`.wd-dropdown-menu`/
6
6
  // `.wd-dropdown-menu-panel` shape is now reused by TopBar.astro (switchers,
7
- // tabs, the small-screen topbar.links ellipsis) and CopyPageMenu.astro, and
8
- // exported from here so any future component - MobileMenu.astro included,
9
- // even though it doesn't render any `.wd-dropdown` markup today - can import
10
- // and call it too without needing to know whether some other component
11
- // already has.
7
+ // tabs, the small-screen topbar.links ellipsis), CopyPageMenu.astro and
8
+ // ApiLangSelect.astro, and exported from here so any of them can call it
9
+ // without needing to know whether some other component already has.
12
10
  //
13
- // That "might get called more than once per page" scenario is exactly why
14
- // the two `document`-level listeners at the bottom are behind their own
15
- // module-level guard, separate from the existing per-trigger
16
- // `dataset.wdInit` guard: querying `.wd-dropdown` from more than one
17
- // component's own `<script>` (each already idempotent per-trigger) would,
18
- // without this, still add a fresh pair of document click/keydown listeners
19
- // on every single call - harmless individually, but wasteful if it keeps
20
- // happening across repeated calls (e.g. once per component that imports
21
- // this, or once per `astro:page-load` firing from more than one place).
11
+ // These are show/hide buttons for a list of links (the W3C's "disclosure"
12
+ // pattern for navigation), not ARIA menus: the button says whether it's
13
+ // open (aria-expanded), the options are ordinary links and buttons that
14
+ // Tab reaches. For the keyboard, on top of that: Down/Up open the list and
15
+ // move between its options (Home/End jump to the ends); Escape closes it
16
+ // and puts focus back on its button; tabbing out of it closes it.
17
+ //
18
+ // The document-level listeners are bound once per page load, and act on
19
+ // whichever dropdowns are open at the time - so they keep working for the
20
+ // ones a client-side navigation brings in.
22
21
  let globalListenersBound = false;
23
22
 
24
- export function initDropdowns(root: ParentNode) {
25
- const dropdowns = Array.from(root.querySelectorAll<HTMLElement>('.wd-dropdown'));
26
- if (dropdowns.length === 0) return;
23
+ const triggerOf = (dropdown: Element) => dropdown.querySelector<HTMLElement>('.wd-dropdown-trigger');
24
+ const optionsOf = (dropdown: Element) =>
25
+ Array.from(dropdown.querySelectorAll<HTMLElement>('.wd-dropdown-menu a[href], .wd-dropdown-menu button'));
27
26
 
28
- const closeAll = () => {
29
- dropdowns.forEach((d) => {
30
- d.classList.remove('open');
31
- d.querySelector('.wd-dropdown-trigger')?.setAttribute('aria-expanded', 'false');
32
- });
33
- };
27
+ function open(dropdown: Element) {
28
+ closeAll(dropdown);
29
+ dropdown.classList.add('open');
30
+ triggerOf(dropdown)?.setAttribute('aria-expanded', 'true');
31
+ }
34
32
 
35
- dropdowns.forEach((dropdown) => {
36
- const trigger = dropdown.querySelector<HTMLButtonElement>('.wd-dropdown-trigger');
33
+ function close(dropdown: Element) {
34
+ dropdown.classList.remove('open');
35
+ triggerOf(dropdown)?.setAttribute('aria-expanded', 'false');
36
+ // Focus on an option that just got hidden would drop to the top of the
37
+ // page - it goes back to the button instead.
38
+ const focused = document.activeElement;
39
+ if (focused && focused !== triggerOf(dropdown) && dropdown.contains(focused)) triggerOf(dropdown)?.focus();
40
+ }
41
+
42
+ function closeAll(except?: Element) {
43
+ document.querySelectorAll('.wd-dropdown.open').forEach((d) => {
44
+ if (d !== except) close(d);
45
+ });
46
+ }
47
+
48
+ export function initDropdowns(root: ParentNode) {
49
+ root.querySelectorAll<HTMLElement>('.wd-dropdown').forEach((dropdown) => {
50
+ const trigger = triggerOf(dropdown);
37
51
  if (!trigger || trigger.dataset.wdInit) return;
38
52
  trigger.dataset.wdInit = 'true';
53
+
39
54
  trigger.addEventListener('click', (e) => {
40
55
  e.stopPropagation();
41
- const isOpen = dropdown.classList.contains('open');
42
- closeAll();
43
- if (!isOpen) {
44
- dropdown.classList.add('open');
45
- trigger.setAttribute('aria-expanded', 'true');
56
+ if (dropdown.classList.contains('open')) close(dropdown);
57
+ else open(dropdown);
58
+ });
59
+
60
+ dropdown.addEventListener('keydown', (e) => {
61
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
62
+ const options = optionsOf(dropdown);
63
+ if (options.length === 0) return;
64
+ e.preventDefault();
65
+ if (!dropdown.classList.contains('open')) open(dropdown);
66
+ const at = options.indexOf(document.activeElement as HTMLElement);
67
+ let next: number;
68
+ if (e.key === 'Home') next = 0;
69
+ else if (e.key === 'End') next = options.length - 1;
70
+ else if (at === -1) next = e.key === 'ArrowUp' ? options.length - 1 : 0;
71
+ else next = Math.min(Math.max(at + (e.key === 'ArrowDown' ? 1 : -1), 0), options.length - 1);
72
+ options[next].focus();
73
+ });
74
+
75
+ // Focus moving somewhere outside the dropdown (Tab, Shift+Tab) closes
76
+ // it. No `relatedTarget` means focus went nowhere in particular - a
77
+ // click on the page, which the click-outside listener already handles.
78
+ dropdown.addEventListener('focusout', (e) => {
79
+ const to = e.relatedTarget as Node | null;
80
+ if (to && !dropdown.contains(to) && dropdown.classList.contains('open')) {
81
+ dropdown.classList.remove('open');
82
+ trigger.setAttribute('aria-expanded', 'false');
46
83
  }
47
84
  });
48
85
  });
49
86
 
50
87
  if (!globalListenersBound) {
51
88
  globalListenersBound = true;
52
- // Bound once against `document`, not `root` - closeAll() above already
53
- // closes every dropdown found on the page regardless of which root a
54
- // given call was scoped to, so there's no reason for these two to be
55
- // scoped any narrower or bound more than once.
56
- document.addEventListener('click', closeAll);
89
+ // A click anywhere closes what's open - including a click on one of its
90
+ // options, which has done its job by then.
91
+ document.addEventListener('click', () => closeAll());
57
92
  document.addEventListener('keydown', (e) => {
58
93
  if (e.key === 'Escape') closeAll();
59
94
  });
@@ -72,15 +72,52 @@ export function initSearch(root: ParentNode) {
72
72
  let currentResults: HTMLElement[] = [];
73
73
  let activeIndex = -1;
74
74
 
75
+ // Down on the last result (or Up on the first) stays there - Enter always
76
+ // has a result to open.
75
77
  function setActive(index: number) {
76
78
  currentResults.forEach((el) => el.classList.remove('wd-search-result-active'));
77
- activeIndex = index >= 0 && index < currentResults.length ? index : -1;
79
+ activeIndex = currentResults.length ? Math.min(Math.max(index, 0), currentResults.length - 1) : -1;
78
80
  if (activeIndex >= 0) {
79
81
  currentResults[activeIndex].classList.add('wd-search-result-active');
80
82
  currentResults[activeIndex].scrollIntoView({ block: 'nearest' });
81
83
  }
82
84
  }
83
85
 
86
+ // Search looks in the version, language and product of the page the reader
87
+ // is on (plus pages outside any of them), with a switch to search
88
+ // everything. [...slug].astro marks each page's scope for Pagefind and
89
+ // for this - see lib/search-scope.js.
90
+ let everything = false;
91
+ const pageScope = () => {
92
+ const el = document.querySelector<HTMLElement>('[data-wd-search-scope]');
93
+ const key = el?.dataset.wdSearchScope ?? 'all';
94
+ return key === 'all' ? null : { key, label: el?.dataset.wdSearchScopeLabel || key };
95
+ };
96
+ function scopeNote(scope: { label: string } | null, found: boolean) {
97
+ if (!scope) return null;
98
+ const note = document.createElement('div');
99
+ note.className = 'wd-search-scope';
100
+ const text = document.createElement('span');
101
+ const button = document.createElement('button');
102
+ button.type = 'button';
103
+ button.className = 'wd-search-scope-toggle';
104
+ if (everything) {
105
+ text.textContent = 'Showing results from the whole site.';
106
+ button.textContent = `Only ${scope.label}`;
107
+ } else {
108
+ text.textContent = found ? `Showing results in ${scope.label}.` : `No results in ${scope.label}.`;
109
+ button.textContent = 'Search everything';
110
+ }
111
+ button.addEventListener('click', (e) => {
112
+ e.stopPropagation();
113
+ everything = !everything;
114
+ input.focus();
115
+ runSearch(input.value);
116
+ });
117
+ note.append(text, ' ', button);
118
+ return note;
119
+ }
120
+
84
121
  async function runSearch(term: string) {
85
122
  if (!term.trim()) {
86
123
  resultsEl.innerHTML = '<div class="wd-search-hint">Start typing to search...</div>';
@@ -94,10 +131,14 @@ export function initSearch(root: ParentNode) {
94
131
  // superseded this call - pagefind's own debounce, on top of (not
95
132
  // instead of) the plain `input` listener below, so a fast typist never
96
133
  // races two searches rendering out of order.
97
- const search = await pf.debouncedSearch(term, {}, 150);
134
+ const scope = pageScope();
135
+ const options = scope && !everything ? { filters: { any: [{ scope: scope.key }, { scope: 'all' }] } } : {};
136
+ const search = await pf.debouncedSearch(term, options, 150);
98
137
  if (search === null) return;
99
138
  if (search.results.length === 0) {
100
139
  resultsEl.innerHTML = '<div class="wd-search-empty">No results.</div>';
140
+ const note = scopeNote(scope, false);
141
+ if (note) resultsEl.replaceChildren(note);
101
142
  currentResults = [];
102
143
  activeIndex = -1;
103
144
  return;
@@ -111,15 +152,27 @@ export function initSearch(root: ParentNode) {
111
152
  const title = document.createElement('div');
112
153
  title.className = 'wd-search-result-title';
113
154
  title.textContent = data.meta?.title ?? data.url;
155
+ // Where the page sits: "API Reference › Webhooks".
156
+ const section = data.meta?.section ? document.createElement('div') : null;
157
+ if (section) {
158
+ section.className = 'wd-search-result-section';
159
+ section.textContent = data.meta.section;
160
+ }
114
161
  const excerpt = document.createElement('div');
115
162
  excerpt.className = 'wd-search-result-excerpt';
116
163
  // innerHTML (not textContent) is deliberate: pagefind returns this
117
164
  // pre-sanitized with <mark> wrapping the matched terms, which is the
118
165
  // whole point - a plain-text excerpt couldn't highlight anything.
119
- excerpt.innerHTML = data.excerpt;
120
- a.append(title, excerpt);
166
+ // The section is indexed as the page's last words, so a short page's
167
+ // excerpt ends with it - shown once, on its own line, unless it's what
168
+ // matched (then the excerpt keeps it, highlighted).
169
+ const trailing = data.meta?.section ? ` ${data.meta.section}.` : null;
170
+ excerpt.innerHTML = trailing && data.excerpt.endsWith(trailing) ? data.excerpt.slice(0, -trailing.length) : data.excerpt;
171
+ a.append(title, ...(section ? [section] : []), excerpt);
121
172
  resultsEl.appendChild(a);
122
173
  }
174
+ const note = scopeNote(scope, true);
175
+ if (note) resultsEl.appendChild(note);
123
176
  currentResults = Array.from(resultsEl.querySelectorAll('.wd-search-result'));
124
177
  setActive(0);
125
178
  }
@@ -128,6 +181,7 @@ export function initSearch(root: ParentNode) {
128
181
  overlay.hidden = false;
129
182
  document.body.style.overflow = 'hidden';
130
183
  input.value = '';
184
+ everything = false;
131
185
  resultsEl.innerHTML = '<div class="wd-search-hint">Start typing to search...</div>';
132
186
  currentResults = [];
133
187
  activeIndex = -1;
@@ -0,0 +1,169 @@
1
+ // A tab row with more tabs than fit slides sideways instead of wrapping:
2
+ // an arrow shows on each side that has more, the active tab starts in
3
+ // view, and keyboard focus, a horizontal wheel/trackpad swipe and a touch
4
+ // swipe move the row too. TopBar.astro renders the markup:
5
+ // [data-tabs-strip] > arrow, .wd-tabs-viewport > .wd-topbar-tabs, arrow.
6
+ //
7
+ // The row is moved with a transform inside an `overflow-x: clip` viewport,
8
+ // not scrolled: a scroll container clips both directions, and would cut
9
+ // off the menus of the tabs and global dropdowns that open below the row.
10
+ // `clip` on one axis leaves the other visible. Browsers without it keep
11
+ // the row wrapping (topbar.css), and this does nothing.
12
+ import { maxOffset, clampOffset, stepOffset, revealOffset } from '../lib/tabs-strip-offset.js';
13
+
14
+ export function initTabsStrips(root: ParentNode) {
15
+ if (!CSS.supports('overflow-x', 'clip')) return;
16
+ root.querySelectorAll<HTMLElement>('[data-tabs-strip]').forEach(initStrip);
17
+ }
18
+
19
+ function initStrip(strip: HTMLElement) {
20
+ if (strip.dataset.wdInit) return;
21
+ strip.dataset.wdInit = 'true';
22
+ const viewport = strip.querySelector<HTMLElement>('.wd-tabs-viewport');
23
+ const track = strip.querySelector<HTMLElement>('.wd-topbar-tabs');
24
+ const prev = strip.querySelector<HTMLElement>('[data-tabs-prev]');
25
+ const next = strip.querySelector<HTMLElement>('[data-tabs-next]');
26
+ if (!viewport || !track || !prev || !next) return;
27
+
28
+ let offset = 0;
29
+ const geometry = () => {
30
+ const view = viewport.clientWidth;
31
+ return { view, max: maxOffset(track.scrollWidth, view), edge: prev.offsetWidth || 36 };
32
+ };
33
+ const apply = () => {
34
+ const { max } = geometry();
35
+ offset = clampOffset(offset, max);
36
+ track.style.transform = offset ? `translateX(${-offset}px)` : '';
37
+ strip.classList.toggle('wd-can-prev', offset > 0);
38
+ strip.classList.toggle('wd-can-next', offset < max);
39
+ };
40
+ // The tab (or dropdown) a node belongs to - a direct child of the row.
41
+ const itemOf = (node: Element | null) => {
42
+ while (node && node.parentElement !== track) node = node.parentElement;
43
+ return node as HTMLElement | null;
44
+ };
45
+ const reveal = (item: HTMLElement | null) => {
46
+ if (!item) return;
47
+ offset = revealOffset(offset, item.offsetLeft, item.offsetLeft + item.offsetWidth, geometry());
48
+ apply();
49
+ };
50
+
51
+ // Where the page starts: the active tab in view, without sliding to it.
52
+ // Tabs widen when the web font arrives, after this first placement - until
53
+ // the reader moves the row, each resize puts the active tab back in view.
54
+ let readerMoved = false;
55
+ const activeItem = itemOf(track.querySelector('.wd-tab-link.active'));
56
+ const settle = () => {
57
+ strip.classList.remove('wd-tabs-animate');
58
+ reveal(activeItem);
59
+ requestAnimationFrame(() => strip.classList.add('wd-tabs-animate'));
60
+ };
61
+ settle();
62
+
63
+ prev.addEventListener('click', (e) => {
64
+ e.stopPropagation();
65
+ readerMoved = true;
66
+ offset = stepOffset(offset, -1, geometry());
67
+ apply();
68
+ });
69
+ next.addEventListener('click', (e) => {
70
+ e.stopPropagation();
71
+ readerMoved = true;
72
+ offset = stepOffset(offset, 1, geometry());
73
+ apply();
74
+ });
75
+ track.addEventListener('focusin', (e) => reveal(itemOf(e.target as Element)));
76
+
77
+ // A sideways swipe on a trackpad, or Shift + wheel. A plain vertical
78
+ // wheel still scrolls the page.
79
+ viewport.addEventListener(
80
+ 'wheel',
81
+ (e) => {
82
+ const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.shiftKey ? e.deltaY : 0;
83
+ if (!delta || geometry().max === 0) return;
84
+ e.preventDefault();
85
+ readerMoved = true;
86
+ strip.classList.remove('wd-tabs-animate');
87
+ offset += delta;
88
+ apply();
89
+ requestAnimationFrame(() => strip.classList.add('wd-tabs-animate'));
90
+ },
91
+ { passive: false }
92
+ );
93
+
94
+ // Touch: a sideways swipe drags the row, and a quick one carries on a
95
+ // little after the finger lifts. `touch-action: pan-y` (topbar.css) leaves
96
+ // sideways moves to this and up/down ones to the page, which scrolls as
97
+ // usual. A swipe that ends on a tab doesn't open it.
98
+ let drag: { id: number; x: number; from: number; moving: boolean; last: [number, number][] } | null = null;
99
+ let swallowClick = false;
100
+ viewport.addEventListener('pointerdown', (e) => {
101
+ if (e.pointerType !== 'touch' || geometry().max === 0) return;
102
+ drag = { id: e.pointerId, x: e.clientX, from: offset, moving: false, last: [[e.clientX, e.timeStamp]] };
103
+ });
104
+ viewport.addEventListener('pointermove', (e) => {
105
+ if (!drag || e.pointerId !== drag.id) return;
106
+ const dx = e.clientX - drag.x;
107
+ if (!drag.moving) {
108
+ if (Math.abs(dx) < 8) return;
109
+ drag.moving = true;
110
+ readerMoved = true;
111
+ strip.classList.remove('wd-tabs-animate');
112
+ viewport.setPointerCapture(e.pointerId);
113
+ }
114
+ drag.last = [...drag.last.slice(-1), [e.clientX, e.timeStamp]];
115
+ offset = drag.from - dx;
116
+ apply();
117
+ });
118
+ const endDrag = (e: PointerEvent) => {
119
+ if (!drag || e.pointerId !== drag.id) return;
120
+ if (drag.moving && e.type === 'pointerup') {
121
+ const [[x0, t0], [x1, t1]] = drag.last.length > 1 ? drag.last : [drag.last[0], drag.last[0]];
122
+ const velocity = t1 > t0 ? (x1 - x0) / (t1 - t0) : 0; // px per ms
123
+ strip.classList.add('wd-tabs-animate');
124
+ offset -= velocity * 180;
125
+ apply();
126
+ swallowClick = true;
127
+ setTimeout(() => (swallowClick = false), 400);
128
+ } else {
129
+ requestAnimationFrame(() => strip.classList.add('wd-tabs-animate'));
130
+ }
131
+ drag = null;
132
+ };
133
+ viewport.addEventListener('pointerup', endDrag);
134
+ viewport.addEventListener('pointercancel', endDrag);
135
+ viewport.addEventListener(
136
+ 'click',
137
+ (e) => {
138
+ if (!swallowClick) return;
139
+ e.preventDefault();
140
+ e.stopPropagation();
141
+ swallowClick = false;
142
+ },
143
+ true
144
+ );
145
+
146
+ // A menu that opens under a tab near the right end would be cut off
147
+ // there - it opens to the left of its tab instead.
148
+ track.querySelectorAll<HTMLElement>('.wd-dropdown').forEach((dropdown) => {
149
+ const menu = dropdown.querySelector<HTMLElement>('.wd-dropdown-menu');
150
+ if (!menu) return;
151
+ const place = () => {
152
+ const shown = menu.style.display;
153
+ menu.style.visibility = 'hidden';
154
+ menu.style.display = 'block';
155
+ const width = menu.offsetWidth;
156
+ menu.style.display = shown;
157
+ menu.style.visibility = '';
158
+ const right = dropdown.getBoundingClientRect().left + width;
159
+ dropdown.classList.toggle('wd-dropdown-align-end', right > viewport.getBoundingClientRect().right);
160
+ };
161
+ dropdown.addEventListener('pointerenter', place);
162
+ dropdown.addEventListener('focusin', place);
163
+ });
164
+
165
+ // Tabs change width as fonts load, and the space changes with the window.
166
+ const resized = () => (readerMoved ? apply() : settle());
167
+ new ResizeObserver(resized).observe(viewport);
168
+ new ResizeObserver(resized).observe(track);
169
+ }
@@ -747,30 +747,48 @@
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
- "perplexity"
763
+ "perplexity",
764
+ "mcp",
765
+ "cursor",
766
+ "vscode"
765
767
  ]
768
+ }
769
+ },
770
+ {
771
+ "type": "object",
772
+ "properties": {
773
+ "openIn": {
774
+ "type": "array",
775
+ "items": {
776
+ "type": "string",
777
+ "enum": [
778
+ "chatgpt",
779
+ "claude",
780
+ "perplexity"
781
+ ]
782
+ },
783
+ "description": "The earlier form: which AI assistants the menu offers, besides copy and view. A list of options replaces it.",
784
+ "markdownDescription": "The earlier form: which AI assistants the menu offers, besides copy and view. A list of options replaces it."
785
+ }
766
786
  },
767
- "description": "Which AI assistants the menu offers. Default: all three.",
768
- "markdownDescription": "Which AI assistants the menu offers. Default: all three."
787
+ "additionalProperties": false
769
788
  }
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\"."
789
+ ],
790
+ "description": "The \"Copy page\" menu on every page - copy as Markdown, view as Markdown, open in ChatGPT, Claude or Perplexity, copy the MCP server URL, connect to Cursor or VS Code - and a Markdown copy of each page at its address + \".md\". On by default with every option (the MCP ones only while `mcp` is on). A list picks the options (\"copy\", \"view\", \"chatgpt\", \"claude\", \"perplexity\", \"mcp\", \"cursor\", \"vscode\"); false turns it all off.",
791
+ "markdownDescription": "The \"Copy page\" menu on every page - copy as Markdown, view as Markdown, open in ChatGPT, Claude or Perplexity, copy the MCP server URL, connect to Cursor or VS Code - and a Markdown copy of each page at its address + \".md\". On by default with every option (the MCP ones only while `mcp` is on). A list picks the options (\"copy\", \"view\", \"chatgpt\", \"claude\", \"perplexity\", \"mcp\", \"cursor\", \"vscode\"); false turns it all off."
774
792
  },
775
793
  "mcp": {
776
794
  "default": true,