@writedocs/generator 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/astro.config.mjs +6 -2
- package/bin/writedocs.js +12 -7
- package/package.json +1 -1
- package/src/cli/build.js +15 -0
- package/src/cli/check-built-styles.js +33 -0
- package/src/cli/convert.js +3 -0
- package/src/cli/dev.js +13 -1
- package/src/cli/open-browser.js +32 -0
- package/src/cli/run-astro.js +4 -0
- package/src/components/ApiLangSelect.astro +1 -2
- package/src/components/CopyPageMenu.astro +161 -40
- package/src/layout/BaseLayout.astro +2 -0
- package/src/layout/components/TopBar.astro +113 -94
- package/src/layout/styles/search-modal.css +27 -0
- package/src/layout/styles/topbar.css +107 -1
- package/src/lib/canonical-path.js +11 -0
- package/src/lib/config-schema.js +119 -7
- package/src/lib/config-schema.ts +158 -20
- package/src/lib/content-check.js +15 -0
- package/src/lib/json-schema-descriptions.js +2 -2
- package/src/lib/link-check.js +2 -2
- package/src/lib/llms-index.ts +3 -3
- package/src/lib/mcp-links.js +30 -0
- package/src/lib/mintlify-convert.js +7 -4
- package/src/lib/search-scope.js +44 -0
- package/src/lib/tabs-strip-offset.js +31 -0
- package/src/lib/writedocs-temp-dir.js +3 -1
- package/src/pages/[...slug].astro +72 -6
- package/src/pages/[...slug].md.ts +8 -8
- package/src/scripts/dropdowns.ts +70 -35
- package/src/scripts/search.ts +58 -4
- package/src/scripts/tabs-strip.ts +169 -0
- package/writedocs.schema.json +33 -15
package/src/lib/config-schema.ts
CHANGED
|
@@ -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
|
-
])
|
|
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
|
|
|
@@ -727,25 +765,58 @@ const apiSchema = z
|
|
|
727
765
|
.strict()
|
|
728
766
|
.default({ proxy: true });
|
|
729
767
|
|
|
730
|
-
// The "Copy page"
|
|
731
|
-
// Markdown
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
768
|
+
// The "Copy page" menu shown next to a page's title - copy the page's
|
|
769
|
+
// Markdown, view it, or open it in an AI assistant - and the .md copy of
|
|
770
|
+
// every page it relies on ([...slug].md.ts). On by default, with every
|
|
771
|
+
// option, like the MCP server. writedocs.json narrows or turns it off:
|
|
772
|
+
//
|
|
773
|
+
// (absent) or true every option
|
|
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)
|
|
779
|
+
// false (or []) no menu, and no .md routes
|
|
780
|
+
// { "openIn": [...] } the earlier form: copy and view, plus these
|
|
781
|
+
// assistants - still accepted
|
|
782
|
+
//
|
|
783
|
+
// Read it through contextMenuOptions() below - never test the raw value for
|
|
784
|
+
// truthiness: absent now means on.
|
|
785
|
+
export const CONTEXT_MENU_OPTIONS = ['copy', 'view', 'chatgpt', 'claude', 'perplexity', 'mcp', 'cursor', 'vscode'] as const;
|
|
786
|
+
export type ContextMenuOption = (typeof CONTEXT_MENU_OPTIONS)[number];
|
|
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;
|
|
790
|
+
|
|
791
|
+
const contextMenuSchema = z.union([
|
|
792
|
+
z.boolean(),
|
|
793
|
+
z.array(z.enum(CONTEXT_MENU_OPTIONS)),
|
|
794
|
+
z
|
|
795
|
+
.object({
|
|
796
|
+
openIn: z.array(z.enum(ASSISTANT_OPTIONS)).optional(),
|
|
797
|
+
})
|
|
798
|
+
.strict(),
|
|
799
|
+
]);
|
|
746
800
|
|
|
747
801
|
export type ContextMenuConfig = z.infer<typeof contextMenuSchema>;
|
|
748
802
|
|
|
803
|
+
/** The menu options a writedocs.json `contextMenu` value turns on, in menu
|
|
804
|
+
* order - [] when the menu is off. Takes the raw value too (link-check.js
|
|
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));
|
|
809
|
+
if (value === false) return [];
|
|
810
|
+
if (value === undefined || value === null || value === true) return [...available];
|
|
811
|
+
if (Array.isArray(value)) return available.filter((option) => value.includes(option));
|
|
812
|
+
if (typeof value === 'object') {
|
|
813
|
+
const openIn = (value as { openIn?: unknown }).openIn;
|
|
814
|
+
const assistants = Array.isArray(openIn) ? openIn : [...ASSISTANT_OPTIONS];
|
|
815
|
+
return CONTEXT_MENU_OPTIONS.filter((option) => option === 'copy' || option === 'view' || assistants.includes(option));
|
|
816
|
+
}
|
|
817
|
+
return [];
|
|
818
|
+
}
|
|
819
|
+
|
|
749
820
|
// One entry in writedocs.json's `redirects` array - wired almost directly into
|
|
750
821
|
// Astro's own `redirects` config option (astro.config.mjs), which is what
|
|
751
822
|
// actually generates the redirect pages. `permanent` is deliberately not
|
|
@@ -996,8 +1067,8 @@ export const docsConfigSchema = z.object({
|
|
|
996
1067
|
// field by field via mergeSeo(), rather than needing to repeat every
|
|
997
1068
|
// field on every page.
|
|
998
1069
|
seo: seoFieldsSchema.default({}),
|
|
999
|
-
// The "Copy page"
|
|
1000
|
-
//
|
|
1070
|
+
// The "Copy page" menu - see contextMenuSchema above. Absent means on,
|
|
1071
|
+
// with every option; read it through contextMenuOptions().
|
|
1001
1072
|
contextMenu: contextMenuSchema.optional(),
|
|
1002
1073
|
// The site's MCP server - an AI tool connects to `/mcp` and searches and
|
|
1003
1074
|
// reads the docs. On by default: `writedocs build` writes the page index
|
|
@@ -1312,6 +1383,73 @@ function desembrulharUnioes(issues: IssueCru[], prefixo: (string | number)[] = [
|
|
|
1312
1383
|
return saida;
|
|
1313
1384
|
}
|
|
1314
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
|
+
|
|
1315
1453
|
/** Exported for `writedocs validate`'s content pass (lib/content-check.js),
|
|
1316
1454
|
* which reports writedocs.json problems of its own - navigation entries
|
|
1317
1455
|
* with no page, unknown icons - with the same line numbers as the schema
|
|
@@ -1564,7 +1702,7 @@ export function validateDocsConfig(rawText: string): ValidationResult {
|
|
|
1564
1702
|
ok: false,
|
|
1565
1703
|
data: null,
|
|
1566
1704
|
kind: 'schema',
|
|
1567
|
-
issues: desembrulharUnioes(result.error.issues as IssueCru[]).map((i) => {
|
|
1705
|
+
issues: containersEmPaginas(desembrulharUnioes(result.error.issues as IssueCru[]), raw).map((i) => {
|
|
1568
1706
|
// Para chave desconhecida o `path` do Zod aponta pro PAI (`footer`), e a
|
|
1569
1707
|
// chave ofensora vem em `keys`. Para a LINHA vale a pena descer ate ela
|
|
1570
1708
|
// (`footer.banana`), que e onde o cliente precisa olhar; o `path` fica
|
package/src/lib/content-check.js
CHANGED
|
@@ -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: '
|
|
168
|
-
'contextMenu.openIn': '
|
|
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
|
+
'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".',
|
package/src/lib/link-check.js
CHANGED
|
@@ -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);
|
package/src/lib/llms-index.ts
CHANGED
|
@@ -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 -
|
|
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
|
|
@@ -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,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 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.
|
|
533
536
|
const options = docs.contextual?.options;
|
|
534
537
|
if (Array.isArray(options) && options.length) {
|
|
535
|
-
const
|
|
536
|
-
out.contextMenu =
|
|
537
|
-
const other = options.filter((o) => typeof o !== 'string' || !
|
|
538
|
+
const known = ['copy', 'view', 'chatgpt', 'claude', 'perplexity', 'mcp', 'cursor', 'vscode'];
|
|
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
|
|
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.');
|
|
540
543
|
}
|
|
541
544
|
}
|
|
542
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
|
-
|
|
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}`;
|
|
@@ -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";
|
|
@@ -68,6 +69,7 @@ import Visibility from "../components/Visibility.astro";
|
|
|
68
69
|
import { Tree, FileTree, Color, GitHub } from "../components/compound";
|
|
69
70
|
import ApiPlayground from "../components/ApiPlayground.astro";
|
|
70
71
|
import ApiReferencePanel from "../components/ApiReferencePanel.astro";
|
|
72
|
+
import { searchScope, sectionTrail, ALL_SCOPES } from "../lib/search-scope.js";
|
|
71
73
|
|
|
72
74
|
// A page is hand-written (the `pages` collection, sourced from anywhere
|
|
73
75
|
// in the project - docs/ has no special status, see findAllPages() in
|
|
@@ -372,6 +374,11 @@ const selectors = buildSelectors(activeSection.path, hrefForSlug, activePagePosi
|
|
|
372
374
|
// <html lang>: the `language` of the navigation level this page sits under,
|
|
373
375
|
// if any. A hidden page has no level of its own (activeSection is only a
|
|
374
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);
|
|
375
382
|
const languageSegment = isHidden ? undefined : activeSection.path.find((segment) => segment.kind === "language");
|
|
376
383
|
const pageLang = languageSegment ? (languageSegment.items[languageSegment.index] as { language: string }).language : undefined;
|
|
377
384
|
const globalDropdowns = buildGlobalDropdowns(
|
|
@@ -414,8 +421,9 @@ const showToc = pageMode === "default";
|
|
|
414
421
|
// branch on separately.
|
|
415
422
|
const isCanvasMode = pageMode === "custom" || pageMode === "blank";
|
|
416
423
|
|
|
417
|
-
// The "Copy page"
|
|
418
|
-
// `contextMenu`
|
|
424
|
+
// The "Copy page" menu (CopyPageMenu.astro) - on by default, with the
|
|
425
|
+
// options writedocs.json's `contextMenu` leaves on (contextMenuOptions() in
|
|
426
|
+
// lib/config-schema.ts; `false` turns it off), and only
|
|
419
427
|
// shown where there's a real auto-rendered <h1> to sit next to: canvas
|
|
420
428
|
// mode pages (custom/blank) have no such header (a hand-built landing
|
|
421
429
|
// page controls its own layout, there's nothing standard to anchor the
|
|
@@ -423,7 +431,8 @@ const isCanvasMode = pageMode === "custom" || pageMode === "blank";
|
|
|
423
431
|
// spec rather than prose - see [...slug].md.ts's own comment on why
|
|
424
432
|
// those are excluded from the .md route this menu links to in the first
|
|
425
433
|
// place, which this mirrors on the UI side.
|
|
426
|
-
const
|
|
434
|
+
const contextMenuItems = contextMenuOptions(config.contextMenu, { mcp: config.mcp });
|
|
435
|
+
const showCopyPageMenu = contextMenuItems.length > 0 && !isCanvasMode && !entry.data.openapi;
|
|
427
436
|
const siteUrl = resolveSiteUrl(config);
|
|
428
437
|
|
|
429
438
|
const components = {
|
|
@@ -495,6 +504,11 @@ const components = {
|
|
|
495
504
|
<div class="wd-canvas" data-pagefind-body>
|
|
496
505
|
<Content components={components} />
|
|
497
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>
|
|
498
512
|
</div>
|
|
499
513
|
) : (
|
|
500
514
|
<article class={`wd-article ${pageMode === "wide" ? "wd-article-wide" : ""}`} data-pagefind-body>
|
|
@@ -513,11 +527,14 @@ const components = {
|
|
|
513
527
|
)
|
|
514
528
|
}
|
|
515
529
|
{showCopyPageMenu && (
|
|
516
|
-
<CopyPageMenu currentPath={currentPath} siteUrl={siteUrl}
|
|
530
|
+
<CopyPageMenu currentPath={currentPath} siteUrl={siteUrl} siteName={config.name} options={contextMenuItems} />
|
|
517
531
|
)}
|
|
518
532
|
</div>
|
|
519
533
|
<Content components={components} />
|
|
520
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>
|
|
521
538
|
{!entry.data.hideFooterPagination && (
|
|
522
539
|
<nav class="wd-prevnext" data-pagefind-ignore>
|
|
523
540
|
{prev && (
|
|
@@ -582,6 +599,8 @@ const components = {
|
|
|
582
599
|
</BaseLayout>
|
|
583
600
|
|
|
584
601
|
<script>
|
|
602
|
+
import { mcpServerUrl, cursorInstallLink, vscodeInstallLink } from "../lib/mcp-links.js";
|
|
603
|
+
|
|
585
604
|
// Astro assigns every h2-h4 an id automatically, but doesn't add a
|
|
586
605
|
// clickable anchor next to it. rehype-autolink-headings can't be used
|
|
587
606
|
// for this directly: Astro's own heading-id rehype plugin always runs
|
|
@@ -645,7 +664,7 @@ const components = {
|
|
|
645
664
|
// isn't otherwise present in the DOM anywhere (the rendered HTML
|
|
646
665
|
// content is not the same text).
|
|
647
666
|
function initCopyPageMenu(root: ParentNode) {
|
|
648
|
-
root.querySelectorAll<HTMLButtonElement>("
|
|
667
|
+
root.querySelectorAll<HTMLButtonElement>("button[data-copy-page]").forEach((btn) => {
|
|
649
668
|
if (btn.dataset.wdInit) return;
|
|
650
669
|
btn.dataset.wdInit = "true";
|
|
651
670
|
const label = btn.querySelector<HTMLElement>(".wd-copy-page-primary-label");
|
|
@@ -672,6 +691,53 @@ const components = {
|
|
|
672
691
|
initCopyPageMenu(document);
|
|
673
692
|
document.addEventListener("astro:page-load", () => initCopyPageMenu(document));
|
|
674
693
|
|
|
694
|
+
// "Open in ChatGPT/Claude/Perplexity" on a site with no writedocs.json
|
|
695
|
+
// `domain`: the page's absolute .md URL isn't known at build time, so the
|
|
696
|
+
// link is completed here from the address the page is served from (see
|
|
697
|
+
// CopyPageMenu.astro) - same prompt the build writes when it does know.
|
|
698
|
+
function initAskLinks(root: ParentNode) {
|
|
699
|
+
root.querySelectorAll<HTMLAnchorElement>("a[data-ask-base][data-md-path]").forEach((link) => {
|
|
700
|
+
const url = new URL(link.dataset.mdPath ?? "/", window.location.origin).href;
|
|
701
|
+
link.href = link.dataset.askBase + encodeURIComponent(`Read ${url} so you can answer questions about it.`);
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
initAskLinks(document);
|
|
705
|
+
document.addEventListener("astro:page-load", () => initAskLinks(document));
|
|
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
|
+
|
|
675
741
|
// Show more/less toggle for ```js expandable code blocks - same
|
|
676
742
|
// build-time-emitted-button + client-wired-click pattern as the copy
|
|
677
743
|
// button above; codeBlockTransformer only adds this button when the
|
|
@@ -845,7 +911,7 @@ const components = {
|
|
|
845
911
|
width: 100%;
|
|
846
912
|
}
|
|
847
913
|
/* Wraps the auto-rendered <h1> together with CopyPageMenu (only
|
|
848
|
-
rendered when writedocs.json's `contextMenu`
|
|
914
|
+
rendered when writedocs.json's `contextMenu` isn't off - see
|
|
849
915
|
showCopyPageMenu above) so the two sit on one row, menu pinned to
|
|
850
916
|
the right. Always present (even with no menu) rather than only
|
|
851
917
|
wrapping the <h1> conditionally, so the <h1>'s own top/bottom
|