@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.
@@ -207,6 +207,9 @@ export type NavigationConfig =
207
207
  | { global?: { dropdowns: DropdownItem[] }; dropdowns: DropdownItem[] }
208
208
  | { global?: { dropdowns: DropdownItem[] }; products: ProductItem[] };
209
209
 
210
+ /** Levels of tabs a page can sit under - one row of the top bar each. */
211
+ export const MAX_TAB_LEVELS = 2;
212
+
210
213
  const globalSchema = z.object({ dropdowns: z.array(dropdownSchema).min(1) });
211
214
 
212
215
  const navigationSchema = z.union([
@@ -216,7 +219,42 @@ const navigationSchema = z.union([
216
219
  z.object({ global: globalSchema.optional(), languages: z.array(languageSchema).min(1) }).strict(),
217
220
  z.object({ global: globalSchema.optional(), dropdowns: z.array(dropdownSchema).min(1) }).strict(),
218
221
  z.object({ global: globalSchema.optional(), products: z.array(productSchema).min(1) }).strict(),
219
- ]) as z.ZodType<NavigationConfig>;
222
+ ]).superRefine((navigation, ctx) => {
223
+ // Every page under a language is in that language - a second `languages`
224
+ // inside it would make a page two languages at once. And tabs take a row
225
+ // of the top bar per level: two levels at most (TopBar.astro).
226
+ const CONTAINERS = ['tabs', 'versions', 'languages', 'dropdowns', 'products'] as const;
227
+ const walk = (node: unknown, path: (string | number)[], language: string | null, tabs: string[]) => {
228
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
229
+ const own = node as Record<string, unknown>;
230
+ for (const key of CONTAINERS) {
231
+ const items = own[key];
232
+ if (!Array.isArray(items)) continue;
233
+ if (key === 'languages' && language !== null) {
234
+ ctx.addIssue({
235
+ code: 'custom',
236
+ path: [...path, key],
237
+ message: `The language "${language}" contains another "languages" list. A page can only be in one language - put the languages at one level, and the rest (tabs, versions, ...) inside each of them.`,
238
+ });
239
+ continue;
240
+ }
241
+ if (key === 'tabs' && tabs.length >= MAX_TAB_LEVELS) {
242
+ ctx.addIssue({
243
+ code: 'custom',
244
+ path: [...path, key],
245
+ message: `Tabs go at most ${MAX_TAB_LEVELS} levels deep, and these are a third level (inside "${tabs.join('" > "')}"). Each level of tabs is a row in the top bar - use a dropdown or groups for this level instead.`,
246
+ });
247
+ continue;
248
+ }
249
+ items.forEach((item, i) => {
250
+ const inner = key === 'languages' ? String((item as { language?: unknown })?.language ?? '') : language;
251
+ const tab = (item as { tab?: unknown })?.tab;
252
+ walk(item, [...path, key, i], inner, key === 'tabs' ? [...tabs, String(tab ?? '')] : tabs);
253
+ });
254
+ }
255
+ };
256
+ walk(navigation, [], null, []);
257
+ }) as z.ZodType<NavigationConfig>;
220
258
 
221
259
  const DEFAULT_PRIMARY = '#6366f1';
222
260
 
@@ -734,15 +772,21 @@ const apiSchema = z
734
772
  //
735
773
  // (absent) or true every option
736
774
  // ["copy", "claude"] only these, in the menu's own order
775
+ // (copy, view, chatgpt, claude, perplexity,
776
+ // mcp, cursor, vscode - the last three connect
777
+ // AI tools to the site's MCP server, and are
778
+ // left out when `mcp` is false)
737
779
  // false (or []) no menu, and no .md routes
738
780
  // { "openIn": [...] } the earlier form: copy and view, plus these
739
781
  // assistants - still accepted
740
782
  //
741
783
  // Read it through contextMenuOptions() below - never test the raw value for
742
784
  // truthiness: absent now means on.
743
- export const CONTEXT_MENU_OPTIONS = ['copy', 'view', 'chatgpt', 'claude', 'perplexity'] as const;
785
+ export const CONTEXT_MENU_OPTIONS = ['copy', 'view', 'chatgpt', 'claude', 'perplexity', 'mcp', 'cursor', 'vscode'] as const;
744
786
  export type ContextMenuOption = (typeof CONTEXT_MENU_OPTIONS)[number];
745
787
  const ASSISTANT_OPTIONS = ['chatgpt', 'claude', 'perplexity'] as const;
788
+ /** The options that point at the site's MCP server - see `mcp` below. */
789
+ export const MCP_MENU_OPTIONS = ['mcp', 'cursor', 'vscode'] as const;
746
790
 
747
791
  const contextMenuSchema = z.union([
748
792
  z.boolean(),
@@ -758,11 +802,13 @@ export type ContextMenuConfig = z.infer<typeof contextMenuSchema>;
758
802
 
759
803
  /** The menu options a writedocs.json `contextMenu` value turns on, in menu
760
804
  * 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[] {
805
+ * reads writedocs.json without the schema), so absent means every option.
806
+ * With `mcp: false` (no MCP server), the MCP options are left out. */
807
+ export function contextMenuOptions(value: unknown, { mcp = true }: { mcp?: boolean } = {}): ContextMenuOption[] {
808
+ const available = CONTEXT_MENU_OPTIONS.filter((option) => mcp || !(MCP_MENU_OPTIONS as readonly string[]).includes(option));
763
809
  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));
810
+ if (value === undefined || value === null || value === true) return [...available];
811
+ if (Array.isArray(value)) return available.filter((option) => value.includes(option));
766
812
  if (typeof value === 'object') {
767
813
  const openIn = (value as { openIn?: unknown }).openIn;
768
814
  const assistants = Array.isArray(openIn) ? openIn : [...ASSISTANT_OPTIONS];
@@ -1337,6 +1383,73 @@ function desembrulharUnioes(issues: IssueCru[], prefixo: (string | number)[] = [
1337
1383
  return saida;
1338
1384
  }
1339
1385
 
1386
+ const LISTAS_DE_CONTAINER = ['tabs', 'versions', 'languages', 'dropdowns', 'products'] as const;
1387
+ const ITENS_DE_CONTAINER: Record<string, { lista: string; nome: string }> = {
1388
+ tab: { lista: 'tabs', nome: 'A tab' },
1389
+ version: { lista: 'versions', nome: 'A version' },
1390
+ language: { lista: 'languages', nome: 'A language' },
1391
+ dropdown: { lista: 'dropdowns', nome: 'A dropdown' },
1392
+ product: { lista: 'products', nome: 'A product' },
1393
+ };
1394
+
1395
+ /** Um container (`{ "tabs": [...] }`, ou um item como `{ "tab": "API", ... }`)
1396
+ * dentro de uma lista de paginas - o `pages` de um grupo, de uma tab etc., ou
1397
+ * a `navigation` em forma de lista. O Zod tenta os tres formatos de item
1398
+ * (pagina, grupo, link) e reclama de cada um: "group is required", "pages is
1399
+ * required", "tabs is not an option" - tres erros, nenhum dizendo o que houve.
1400
+ * Aqui eles viram UM, no item, dizendo onde o container pode ficar. */
1401
+ function containersEmPaginas(issues: IssuePlano[], raiz: unknown): IssuePlano[] {
1402
+ const vistos = new Set<string>();
1403
+ const saida: IssuePlano[] = [];
1404
+ for (const issue of issues) {
1405
+ const achado = containerEmPaginas(issue.caminho, raiz);
1406
+ if (!achado) {
1407
+ saida.push(issue);
1408
+ continue;
1409
+ }
1410
+ const chave = achado.caminho.join('.');
1411
+ if (vistos.has(chave)) continue;
1412
+ vistos.add(chave);
1413
+ saida.push({ ...issue, code: 'custom', caminho: achado.caminho, message: achado.mensagem, keys: undefined });
1414
+ }
1415
+ return saida;
1416
+ }
1417
+
1418
+ function containerEmPaginas(caminho: (string | number)[], raiz: unknown): { caminho: (string | number)[]; mensagem: string } | null {
1419
+ for (let k = 1; k < caminho.length; k++) {
1420
+ if (typeof caminho[k] !== 'number') continue;
1421
+ const naRaiz = k === 1 && caminho[0] === 'navigation';
1422
+ if (!naRaiz && caminho[k - 1] !== 'pages') continue;
1423
+ const item = valorEm(raiz, caminho.slice(0, k + 1));
1424
+ if (!item || typeof item !== 'object' || Array.isArray(item) || 'group' in item) continue;
1425
+ const lugar = naRaiz ? 'the navigation list' : 'a "pages" list';
1426
+ const rotulo = (texto: string) => (naRaiz ? 'The navigation list' : 'A "pages" list') + ` holds pages, groups and links only. ${texto}`;
1427
+ const lista = LISTAS_DE_CONTAINER.find((l) => l in item);
1428
+ if (lista) {
1429
+ return {
1430
+ caminho: caminho.slice(0, k + 1),
1431
+ mensagem: `"${lista}" can't go inside ${lugar}. ` + rotulo(
1432
+ naRaiz
1433
+ ? `To use ${lista}, make "navigation" an object: "navigation": { "${lista}": [ ... ] }, with the groups inside each of them.`
1434
+ : `Put "${lista}" on "navigation" itself, or on a tab, version, language, dropdown or product in place of its "pages".`
1435
+ ),
1436
+ };
1437
+ }
1438
+ const tipo = Object.keys(ITENS_DE_CONTAINER).find((t) => t in item);
1439
+ if (tipo) {
1440
+ const { lista: dela, nome } = ITENS_DE_CONTAINER[tipo];
1441
+ const nomeDoItem = (item as Record<string, unknown>)[tipo];
1442
+ return {
1443
+ caminho: caminho.slice(0, k + 1),
1444
+ mensagem: `${nome}${typeof nomeDoItem === 'string' ? ` ("${nomeDoItem}")` : ''} can't go inside ${lugar}. ` + rotulo(
1445
+ naRaiz ? `${nome} goes in a "${dela}" list: "navigation": { "${dela}": [ ... ] }.` : `${nome} goes in a "${dela}" list.`
1446
+ ),
1447
+ };
1448
+ }
1449
+ }
1450
+ return null;
1451
+ }
1452
+
1340
1453
  /** Exported for `writedocs validate`'s content pass (lib/content-check.js),
1341
1454
  * which reports writedocs.json problems of its own - navigation entries
1342
1455
  * with no page, unknown icons - with the same line numbers as the schema
@@ -1589,7 +1702,7 @@ export function validateDocsConfig(rawText: string): ValidationResult {
1589
1702
  ok: false,
1590
1703
  data: null,
1591
1704
  kind: 'schema',
1592
- issues: desembrulharUnioes(result.error.issues as IssueCru[]).map((i) => {
1705
+ issues: containersEmPaginas(desembrulharUnioes(result.error.issues as IssueCru[]), raw).map((i) => {
1593
1706
  // Para chave desconhecida o `path` do Zod aponta pro PAI (`footer`), e a
1594
1707
  // chave ofensora vem em `keys`. Para a LINHA vale a pena descer ate ela
1595
1708
  // (`footer.banana`), que e onde o cliente precisa olhar; o `path` fica
@@ -164,7 +164,7 @@ 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: '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.',
167
+ contextMenu: '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.',
168
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.',
@@ -0,0 +1,30 @@
1
+ // The "Copy page" menu's MCP options (CopyPageMenu.astro): the site's MCP
2
+ // server address, and the links that add it to Cursor and to VS Code. Used
3
+ // at build time when writedocs.json has a `domain`, and in the browser
4
+ // (from the page's own address) when it doesn't - so plain functions, no
5
+ // Node APIs.
6
+
7
+ /** The site's MCP server - served at /mcp (see docs/dev/docs/mcp.mdx). */
8
+ export function mcpServerUrl(origin) {
9
+ return `${String(origin).replace(/\/+$/, '')}/mcp`;
10
+ }
11
+
12
+ function base64(text) {
13
+ // UTF-8 first, so a site name with accents survives btoa().
14
+ const bytes = new TextEncoder().encode(text);
15
+ let binary = '';
16
+ for (const byte of bytes) binary += String.fromCharCode(byte);
17
+ return btoa(binary);
18
+ }
19
+
20
+ /** Cursor's install link: the server's name, and its config as base64 JSON
21
+ * (https://docs.cursor.com/deeplinks). */
22
+ export function cursorInstallLink(name, url) {
23
+ return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(name)}&config=${encodeURIComponent(base64(JSON.stringify({ url })))}`;
24
+ }
25
+
26
+ /** VS Code's install link: the server's config as URL-encoded JSON
27
+ * (https://code.visualstudio.com/docs/copilot/chat/mcp-servers). */
28
+ export function vscodeInstallLink(name, url) {
29
+ return `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name, type: 'http', url }))}`;
30
+ }
@@ -530,16 +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
+ // Mintlify's option names are writedocs' own for the eight both have, so
534
+ // the list carries over as is. No `contextual` leaves the field out - the
535
+ // menu is on by default, with every option.
536
536
  const options = docs.contextual?.options;
537
537
  if (Array.isArray(options) && options.length) {
538
- const known = ['copy', 'view', 'chatgpt', 'claude', 'perplexity'];
538
+ const known = ['copy', 'view', 'chatgpt', 'claude', 'perplexity', 'mcp', 'cursor', 'vscode'];
539
539
  out.contextMenu = known.filter((o) => options.includes(o));
540
540
  const other = options.filter((o) => typeof o !== 'string' || !known.includes(o));
541
541
  if (other.length) {
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.');
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, open in ChatGPT, Claude and Perplexity, and the MCP server: copy its URL, connect to Cursor or VS Code.');
543
543
  }
544
544
  }
545
545
 
@@ -0,0 +1,44 @@
1
+ // What search knows about a page beyond its text: where it sits (the
2
+ // section line under a result's title, and words a search can match - a
3
+ // page under the "Webhooks" tab is found by "webhooks"), and which version,
4
+ // language and product it belongs to (search shows those of the page the
5
+ // reader is on, with a way to search everything). Built from a Section's
6
+ // `path` (resolveSections() in lib/config.ts) and the page's breadcrumb
7
+ // groups. src/scripts/search.ts reads both back.
8
+
9
+ /** Every page outside any version, language and product - shown whatever
10
+ * the reader is on. */
11
+ export const ALL_SCOPES = 'all';
12
+
13
+ const SCOPE_KINDS = { version: 'version', language: 'language', product: 'product' };
14
+
15
+ function segmentValue(segment) {
16
+ const item = segment.items[segment.index] ?? {};
17
+ switch (segment.kind) {
18
+ case 'tab': return { id: item.tab, label: item.tab };
19
+ case 'dropdown': return { id: item.dropdown, label: item.dropdown };
20
+ case 'product': return { id: item.product, label: item.product };
21
+ case 'version': return { id: item.version, label: item.label ?? item.version };
22
+ case 'language': return { id: item.language, label: item.label ?? item.language };
23
+ default: return { id: '', label: '' };
24
+ }
25
+ }
26
+
27
+ /** `key` - the same for every page of one version/language/product
28
+ * combination; `label` - how the search box names it ("v2 · English"). */
29
+ export function searchScope(path = []) {
30
+ const parts = path.filter((s) => s.kind in SCOPE_KINDS).map((s) => ({ kind: s.kind, ...segmentValue(s) }));
31
+ if (parts.length === 0) return { key: ALL_SCOPES, label: '' };
32
+ return {
33
+ // Pagefind's `data-pagefind-filter="scope:..."` reads a comma as the next
34
+ // filter and the first colon as the end of the name.
35
+ key: parts.map((p) => `${p.kind}=${String(p.id).replace(/[,:]/g, '_')}`).join('/'),
36
+ label: parts.map((p) => p.label).join(' · '),
37
+ };
38
+ }
39
+
40
+ /** "API Reference › Webhooks › Events group": every level above the page,
41
+ * navigation containers first, then its groups. */
42
+ export function sectionTrail(path = [], groups = []) {
43
+ return [...path.map((s) => segmentValue(s).label), ...groups.map((g) => g.label)].filter(Boolean).join(' › ');
44
+ }
@@ -0,0 +1,31 @@
1
+ // The arithmetic behind a tab row that slides sideways (src/scripts/
2
+ // tabs-strip.ts), kept apart from the DOM so it can be tested. Every
3
+ // value is in pixels: `offset` is how far the row is slid to the left,
4
+ // `view` the visible width, `track` the width of all the tabs together,
5
+ // and `edge` how much of each end an arrow covers when it shows.
6
+
7
+ /** How far the row can slide: 0 when every tab fits. */
8
+ export function maxOffset(track, view) {
9
+ return Math.max(0, Math.round(track - view));
10
+ }
11
+
12
+ export function clampOffset(offset, max) {
13
+ return Math.min(Math.max(0, offset), max);
14
+ }
15
+
16
+ /** One arrow click: close to a whole visible width, minus what the arrows
17
+ * cover, so the tab cut off at the edge becomes the first one shown. */
18
+ export function stepOffset(offset, direction, { view, max, edge }) {
19
+ const distance = Math.max(view - 2 * edge, view / 2);
20
+ return clampOffset(offset + direction * distance, max);
21
+ }
22
+
23
+ /** The offset that shows the item spanning `start`..`end` (measured from the
24
+ * row's own left edge) clear of the arrows, moving as little as possible.
25
+ * An item wider than the space shows from its start. */
26
+ export function revealOffset(offset, start, end, { view, max, edge }) {
27
+ let next = offset;
28
+ if (end > offset + view - edge) next = end - view + edge;
29
+ if (start < next + edge) next = start - edge;
30
+ return clampOffset(next, max);
31
+ }
@@ -1,6 +1,7 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
+ import { canonicalPath } from './canonical-path.js';
4
5
 
5
6
  /** Where writedocs' own generated/cached artifacts for a given content
6
7
  * directory live: Astro's content-layer cache (`cacheDir`), the
@@ -36,7 +37,8 @@ import crypto from 'node:crypto';
36
37
  * - all of them need to agree on the identical path, since one writes
37
38
  * what another reads. */
38
39
  function namespaceLabel(contentDir) {
39
- const resolved = path.resolve(contentDir);
40
+ // `c:\docs` and `C:\docs` are one project - one directory.
41
+ const resolved = canonicalPath(path.resolve(contentDir));
40
42
  const hash = crypto.createHash('sha1').update(resolved).digest('hex').slice(0, 12);
41
43
  const label = path.basename(resolved) || 'root';
42
44
  return `${label}-${hash}`;
@@ -69,6 +69,7 @@ import Visibility from "../components/Visibility.astro";
69
69
  import { Tree, FileTree, Color, GitHub } from "../components/compound";
70
70
  import ApiPlayground from "../components/ApiPlayground.astro";
71
71
  import ApiReferencePanel from "../components/ApiReferencePanel.astro";
72
+ import { searchScope, sectionTrail, ALL_SCOPES } from "../lib/search-scope.js";
72
73
 
73
74
  // A page is hand-written (the `pages` collection, sourced from anywhere
74
75
  // in the project - docs/ has no special status, see findAllPages() in
@@ -373,6 +374,11 @@ const selectors = buildSelectors(activeSection.path, hrefForSlug, activePagePosi
373
374
  // <html lang>: the `language` of the navigation level this page sits under,
374
375
  // if any. A hidden page has no level of its own (activeSection is only a
375
376
  // fallback for its chrome), so it keeps the default.
377
+ // For search: the section line under this page's result (and words it can
378
+ // be found by), and the version/language/product it belongs to - search
379
+ // shows the reader's own first. A hidden page belongs to no section.
380
+ const pageSearchScope = isHidden ? { key: ALL_SCOPES, label: "" } : searchScope(activeSection.path);
381
+ const pageSectionTrail = isHidden ? "" : sectionTrail(activeSection.path, breadcrumbs);
376
382
  const languageSegment = isHidden ? undefined : activeSection.path.find((segment) => segment.kind === "language");
377
383
  const pageLang = languageSegment ? (languageSegment.items[languageSegment.index] as { language: string }).language : undefined;
378
384
  const globalDropdowns = buildGlobalDropdowns(
@@ -425,7 +431,7 @@ const isCanvasMode = pageMode === "custom" || pageMode === "blank";
425
431
  // spec rather than prose - see [...slug].md.ts's own comment on why
426
432
  // those are excluded from the .md route this menu links to in the first
427
433
  // place, which this mirrors on the UI side.
428
- const contextMenuItems = contextMenuOptions(config.contextMenu);
434
+ const contextMenuItems = contextMenuOptions(config.contextMenu, { mcp: config.mcp });
429
435
  const showCopyPageMenu = contextMenuItems.length > 0 && !isCanvasMode && !entry.data.openapi;
430
436
  const siteUrl = resolveSiteUrl(config);
431
437
 
@@ -498,6 +504,11 @@ const components = {
498
504
  <div class="wd-canvas" data-pagefind-body>
499
505
  <Content components={components} />
500
506
  {entry.data.openapi && <ApiPlayground operation={entry.data.openapi} contentDir={contentDir} />}
507
+ {/* After the content: a result's excerpt shows the section only when
508
+ that's what matched. */}
509
+ <div hidden data-wd-search-scope={pageSearchScope.key} data-wd-search-scope-label={pageSearchScope.label} data-pagefind-filter={`scope:${pageSearchScope.key}`}>
510
+ {pageSectionTrail && <span data-pagefind-meta="section">{pageSectionTrail}</span>}
511
+ </div>
501
512
  </div>
502
513
  ) : (
503
514
  <article class={`wd-article ${pageMode === "wide" ? "wd-article-wide" : ""}`} data-pagefind-body>
@@ -516,11 +527,14 @@ const components = {
516
527
  )
517
528
  }
518
529
  {showCopyPageMenu && (
519
- <CopyPageMenu currentPath={currentPath} siteUrl={siteUrl} options={contextMenuItems} />
530
+ <CopyPageMenu currentPath={currentPath} siteUrl={siteUrl} siteName={config.name} options={contextMenuItems} />
520
531
  )}
521
532
  </div>
522
533
  <Content components={components} />
523
534
  {entry.data.openapi && <ApiPlayground operation={entry.data.openapi} contentDir={contentDir} />}
535
+ <div hidden data-wd-search-scope={pageSearchScope.key} data-wd-search-scope-label={pageSearchScope.label} data-pagefind-filter={`scope:${pageSearchScope.key}`}>
536
+ {pageSectionTrail && <span data-pagefind-meta="section">{pageSectionTrail}</span>}
537
+ </div>
524
538
  {!entry.data.hideFooterPagination && (
525
539
  <nav class="wd-prevnext" data-pagefind-ignore>
526
540
  {prev && (
@@ -585,6 +599,8 @@ const components = {
585
599
  </BaseLayout>
586
600
 
587
601
  <script>
602
+ import { mcpServerUrl, cursorInstallLink, vscodeInstallLink } from "../lib/mcp-links.js";
603
+
588
604
  // Astro assigns every h2-h4 an id automatically, but doesn't add a
589
605
  // clickable anchor next to it. rehype-autolink-headings can't be used
590
606
  // for this directly: Astro's own heading-id rehype plugin always runs
@@ -648,7 +664,7 @@ const components = {
648
664
  // isn't otherwise present in the DOM anywhere (the rendered HTML
649
665
  // content is not the same text).
650
666
  function initCopyPageMenu(root: ParentNode) {
651
- root.querySelectorAll<HTMLButtonElement>(".wd-copy-page-primary").forEach((btn) => {
667
+ root.querySelectorAll<HTMLButtonElement>("button[data-copy-page]").forEach((btn) => {
652
668
  if (btn.dataset.wdInit) return;
653
669
  btn.dataset.wdInit = "true";
654
670
  const label = btn.querySelector<HTMLElement>(".wd-copy-page-primary-label");
@@ -688,6 +704,40 @@ const components = {
688
704
  initAskLinks(document);
689
705
  document.addEventListener("astro:page-load", () => initAskLinks(document));
690
706
 
707
+ // The menu's MCP options (CopyPageMenu.astro). "Copy MCP server URL"
708
+ // copies it and says so in its own label - inside the menu, which stays
709
+ // open to show it. "Connect to Cursor / VS Code" on a site with no
710
+ // `domain` get their install links here, for the address the page is
711
+ // served from.
712
+ function initMcpMenu(root: ParentNode) {
713
+ const serverUrl = (el: HTMLElement) => el.dataset.copyMcp || mcpServerUrl(window.location.origin);
714
+ root.querySelectorAll<HTMLButtonElement>("button[data-copy-mcp]").forEach((btn) => {
715
+ if (btn.dataset.wdInit) return;
716
+ btn.dataset.wdInit = "true";
717
+ const label = btn.querySelector<HTMLElement>(".wd-copy-page-item-title, .wd-copy-page-primary-label");
718
+ const originalLabel = label?.textContent ?? "";
719
+ btn.addEventListener("click", async (e) => {
720
+ e.stopPropagation();
721
+ try {
722
+ await navigator.clipboard.writeText(serverUrl(btn));
723
+ } catch {
724
+ return;
725
+ }
726
+ if (label) label.textContent = "Copied!";
727
+ window.setTimeout(() => {
728
+ if (label) label.textContent = originalLabel;
729
+ }, 1500);
730
+ });
731
+ });
732
+ root.querySelectorAll<HTMLAnchorElement>("a[data-mcp-install]").forEach((link) => {
733
+ const url = mcpServerUrl(window.location.origin);
734
+ const name = link.dataset.mcpName ?? document.title;
735
+ link.href = link.dataset.mcpInstall === "cursor" ? cursorInstallLink(name, url) : vscodeInstallLink(name, url);
736
+ });
737
+ }
738
+ initMcpMenu(document);
739
+ document.addEventListener("astro:page-load", () => initMcpMenu(document));
740
+
691
741
  // Show more/less toggle for ```js expandable code blocks - same
692
742
  // build-time-emitted-button + client-wired-click pattern as the copy
693
743
  // button above; codeBlockTransformer only adds this button when the
@@ -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
  });