@writedocs/generator 0.8.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/astro.config.mjs +2 -1
- package/bin/writedocs.js +10 -7
- package/package.json +1 -1
- package/src/cli/build.js +21 -0
- package/src/cli/check-built-styles.js +33 -0
- package/src/cli/dev.js +4 -0
- package/src/cli/rewrite-redirect-pages.js +84 -0
- package/src/cli/run-astro.js +4 -0
- package/src/components/ApiLangSelect.astro +1 -2
- package/src/components/CopyPageMenu.astro +122 -34
- package/src/layout/BaseLayout.astro +2 -0
- package/src/layout/components/MobileMenu.astro +10 -4
- package/src/layout/components/Sidebar.astro +110 -2
- package/src/layout/components/TopBar.astro +127 -103
- package/src/layout/styles/mobile-menu.css +6 -0
- package/src/layout/styles/search-modal.css +27 -0
- package/src/layout/styles/topbar.css +113 -1
- package/src/lib/canonical-path.js +11 -0
- package/src/lib/config-schema.js +99 -6
- package/src/lib/config-schema.ts +120 -7
- package/src/lib/config.ts +4 -0
- package/src/lib/json-schema-descriptions.js +1 -1
- package/src/lib/mcp-links.js +30 -0
- package/src/lib/mintlify-convert.js +5 -5
- package/src/lib/search-scope.js +44 -0
- package/src/lib/selector-placement.js +30 -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 -5
- 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 +6 -3
package/src/lib/config-schema.js
CHANGED
|
@@ -63,6 +63,7 @@ const dropdownSchema = z.lazy(
|
|
|
63
63
|
const productSchema = z.lazy(
|
|
64
64
|
() => withChildren({ product: z.string(), icon: z.string().optional(), description: z.string().optional() })
|
|
65
65
|
);
|
|
66
|
+
const MAX_TAB_LEVELS = 2;
|
|
66
67
|
const globalSchema = z.object({ dropdowns: z.array(dropdownSchema).min(1) });
|
|
67
68
|
const navigationSchema = z.union([
|
|
68
69
|
z.array(navItemSchema),
|
|
@@ -71,7 +72,39 @@ const navigationSchema = z.union([
|
|
|
71
72
|
z.object({ global: globalSchema.optional(), languages: z.array(languageSchema).min(1) }).strict(),
|
|
72
73
|
z.object({ global: globalSchema.optional(), dropdowns: z.array(dropdownSchema).min(1) }).strict(),
|
|
73
74
|
z.object({ global: globalSchema.optional(), products: z.array(productSchema).min(1) }).strict()
|
|
74
|
-
])
|
|
75
|
+
]).superRefine((navigation, ctx) => {
|
|
76
|
+
const CONTAINERS = ["tabs", "versions", "languages", "dropdowns", "products"];
|
|
77
|
+
const walk = (node, path, language, tabs) => {
|
|
78
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return;
|
|
79
|
+
const own = node;
|
|
80
|
+
for (const key of CONTAINERS) {
|
|
81
|
+
const items = own[key];
|
|
82
|
+
if (!Array.isArray(items)) continue;
|
|
83
|
+
if (key === "languages" && language !== null) {
|
|
84
|
+
ctx.addIssue({
|
|
85
|
+
code: "custom",
|
|
86
|
+
path: [...path, key],
|
|
87
|
+
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.`
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (key === "tabs" && tabs.length >= MAX_TAB_LEVELS) {
|
|
92
|
+
ctx.addIssue({
|
|
93
|
+
code: "custom",
|
|
94
|
+
path: [...path, key],
|
|
95
|
+
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.`
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
items.forEach((item, i) => {
|
|
100
|
+
const inner = key === "languages" ? String(item?.language ?? "") : language;
|
|
101
|
+
const tab = item?.tab;
|
|
102
|
+
walk(item, [...path, key, i], inner, key === "tabs" ? [...tabs, String(tab ?? "")] : tabs);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
walk(navigation, [], null, []);
|
|
107
|
+
});
|
|
75
108
|
const DEFAULT_PRIMARY = "#6366f1";
|
|
76
109
|
const logoSchema = z.union([
|
|
77
110
|
z.string(),
|
|
@@ -390,8 +423,9 @@ const apiSchema = z.object({
|
|
|
390
423
|
// site can set this to `false` to never involve a third party.
|
|
391
424
|
proxy: z.boolean().default(true)
|
|
392
425
|
}).strict().default({ proxy: true });
|
|
393
|
-
const CONTEXT_MENU_OPTIONS = ["copy", "view", "chatgpt", "claude", "perplexity"];
|
|
426
|
+
const CONTEXT_MENU_OPTIONS = ["copy", "view", "chatgpt", "claude", "perplexity", "mcp", "cursor", "vscode"];
|
|
394
427
|
const ASSISTANT_OPTIONS = ["chatgpt", "claude", "perplexity"];
|
|
428
|
+
const MCP_MENU_OPTIONS = ["mcp", "cursor", "vscode"];
|
|
395
429
|
const contextMenuSchema = z.union([
|
|
396
430
|
z.boolean(),
|
|
397
431
|
z.array(z.enum(CONTEXT_MENU_OPTIONS)),
|
|
@@ -399,10 +433,11 @@ const contextMenuSchema = z.union([
|
|
|
399
433
|
openIn: z.array(z.enum(ASSISTANT_OPTIONS)).optional()
|
|
400
434
|
}).strict()
|
|
401
435
|
]);
|
|
402
|
-
function contextMenuOptions(value) {
|
|
436
|
+
function contextMenuOptions(value, { mcp = true } = {}) {
|
|
437
|
+
const available = CONTEXT_MENU_OPTIONS.filter((option) => mcp || !MCP_MENU_OPTIONS.includes(option));
|
|
403
438
|
if (value === false) return [];
|
|
404
|
-
if (value === void 0 || value === null || value === true) return [...
|
|
405
|
-
if (Array.isArray(value)) return
|
|
439
|
+
if (value === void 0 || value === null || value === true) return [...available];
|
|
440
|
+
if (Array.isArray(value)) return available.filter((option) => value.includes(option));
|
|
406
441
|
if (typeof value === "object") {
|
|
407
442
|
const openIn = value.openIn;
|
|
408
443
|
const assistants = Array.isArray(openIn) ? openIn : [...ASSISTANT_OPTIONS];
|
|
@@ -643,6 +678,62 @@ function desembrulharUnioes(issues, prefixo = []) {
|
|
|
643
678
|
}
|
|
644
679
|
return saida;
|
|
645
680
|
}
|
|
681
|
+
const LISTAS_DE_CONTAINER = ["tabs", "versions", "languages", "dropdowns", "products"];
|
|
682
|
+
const ITENS_DE_CONTAINER = {
|
|
683
|
+
tab: { lista: "tabs", nome: "A tab" },
|
|
684
|
+
version: { lista: "versions", nome: "A version" },
|
|
685
|
+
language: { lista: "languages", nome: "A language" },
|
|
686
|
+
dropdown: { lista: "dropdowns", nome: "A dropdown" },
|
|
687
|
+
product: { lista: "products", nome: "A product" }
|
|
688
|
+
};
|
|
689
|
+
function containersEmPaginas(issues, raiz) {
|
|
690
|
+
const vistos = /* @__PURE__ */ new Set();
|
|
691
|
+
const saida = [];
|
|
692
|
+
for (const issue of issues) {
|
|
693
|
+
const achado = containerEmPaginas(issue.caminho, raiz);
|
|
694
|
+
if (!achado) {
|
|
695
|
+
saida.push(issue);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
const chave = achado.caminho.join(".");
|
|
699
|
+
if (vistos.has(chave)) continue;
|
|
700
|
+
vistos.add(chave);
|
|
701
|
+
saida.push({ ...issue, code: "custom", caminho: achado.caminho, message: achado.mensagem, keys: void 0 });
|
|
702
|
+
}
|
|
703
|
+
return saida;
|
|
704
|
+
}
|
|
705
|
+
function containerEmPaginas(caminho, raiz) {
|
|
706
|
+
for (let k = 1; k < caminho.length; k++) {
|
|
707
|
+
if (typeof caminho[k] !== "number") continue;
|
|
708
|
+
const naRaiz = k === 1 && caminho[0] === "navigation";
|
|
709
|
+
if (!naRaiz && caminho[k - 1] !== "pages") continue;
|
|
710
|
+
const item = valorEm(raiz, caminho.slice(0, k + 1));
|
|
711
|
+
if (!item || typeof item !== "object" || Array.isArray(item) || "group" in item) continue;
|
|
712
|
+
const lugar = naRaiz ? "the navigation list" : 'a "pages" list';
|
|
713
|
+
const rotulo = (texto) => (naRaiz ? "The navigation list" : 'A "pages" list') + ` holds pages, groups and links only. ${texto}`;
|
|
714
|
+
const lista = LISTAS_DE_CONTAINER.find((l) => l in item);
|
|
715
|
+
if (lista) {
|
|
716
|
+
return {
|
|
717
|
+
caminho: caminho.slice(0, k + 1),
|
|
718
|
+
mensagem: `"${lista}" can't go inside ${lugar}. ` + rotulo(
|
|
719
|
+
naRaiz ? `To use ${lista}, make "navigation" an object: "navigation": { "${lista}": [ ... ] }, with the groups inside each of them.` : `Put "${lista}" on "navigation" itself, or on a tab, version, language, dropdown or product in place of its "pages".`
|
|
720
|
+
)
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
const tipo = Object.keys(ITENS_DE_CONTAINER).find((t) => t in item);
|
|
724
|
+
if (tipo) {
|
|
725
|
+
const { lista: dela, nome } = ITENS_DE_CONTAINER[tipo];
|
|
726
|
+
const nomeDoItem = item[tipo];
|
|
727
|
+
return {
|
|
728
|
+
caminho: caminho.slice(0, k + 1),
|
|
729
|
+
mensagem: `${nome}${typeof nomeDoItem === "string" ? ` ("${nomeDoItem}")` : ""} can't go inside ${lugar}. ` + rotulo(
|
|
730
|
+
naRaiz ? `${nome} goes in a "${dela}" list: "navigation": { "${dela}": [ ... ] }.` : `${nome} goes in a "${dela}" list.`
|
|
731
|
+
)
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
646
737
|
function createJsonLocator(rawText) {
|
|
647
738
|
return criarLocalizador(rawText);
|
|
648
739
|
}
|
|
@@ -830,7 +921,7 @@ function validateDocsConfig(rawText) {
|
|
|
830
921
|
ok: false,
|
|
831
922
|
data: null,
|
|
832
923
|
kind: "schema",
|
|
833
|
-
issues: desembrulharUnioes(result.error.issues).map((i) => {
|
|
924
|
+
issues: containersEmPaginas(desembrulharUnioes(result.error.issues), raw).map((i) => {
|
|
834
925
|
const alvoDaLinha = i.code === "unrecognized_keys" && i.keys?.length ? [...i.caminho, i.keys[0]] : i.caminho;
|
|
835
926
|
return {
|
|
836
927
|
path: i.caminho.join(".") || "(root)",
|
|
@@ -847,6 +938,8 @@ function validateDocsConfig(rawText) {
|
|
|
847
938
|
}
|
|
848
939
|
export {
|
|
849
940
|
CONTEXT_MENU_OPTIONS,
|
|
941
|
+
MAX_TAB_LEVELS,
|
|
942
|
+
MCP_MENU_OPTIONS,
|
|
850
943
|
ROOT_ALLOWED_EXTRA_KEYS,
|
|
851
944
|
contextMenuOptions,
|
|
852
945
|
createJsonLocator,
|
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
|
|
|
@@ -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
|
-
|
|
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 [...
|
|
765
|
-
if (Array.isArray(value)) return
|
|
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
|
package/src/lib/config.ts
CHANGED
|
@@ -909,6 +909,9 @@ export interface SelectorOption {
|
|
|
909
909
|
label: string;
|
|
910
910
|
icon?: string;
|
|
911
911
|
tag?: string;
|
|
912
|
+
// A product's `description` - shown under its name where there's room
|
|
913
|
+
// (the sidebar's product switcher, see lib/selector-placement.js).
|
|
914
|
+
description?: string;
|
|
912
915
|
href: string;
|
|
913
916
|
active: boolean;
|
|
914
917
|
// Set when this option's own container is a `dropdowns` list (e.g. a
|
|
@@ -976,6 +979,7 @@ export function buildSelectors(
|
|
|
976
979
|
label: labelOf(item),
|
|
977
980
|
icon: iconOf(item),
|
|
978
981
|
tag: 'tag' in item ? item.tag : undefined,
|
|
982
|
+
description: 'description' in item ? item.description : undefined,
|
|
979
983
|
href: 'href' in item ? item.href : hrefForSlug(targetSlug),
|
|
980
984
|
active: isActive,
|
|
981
985
|
dropdown: dropdownMenuOf(item, hrefForSlug, isActive ? path[segIndex + 1] : undefined),
|
|
@@ -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
|
|
534
|
-
// list carries over as is. No `contextual` leaves the field out - the
|
|
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,
|
|
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,30 @@
|
|
|
1
|
+
// Where each level of a page's navigation path shows its switcher - one
|
|
2
|
+
// entry per Selector (buildSelectors() in lib/config.ts), root first:
|
|
3
|
+
//
|
|
4
|
+
// 'tabs' a row of tabs in the top bar (a second row for tabs inside tabs)
|
|
5
|
+
// 'tab-menu' the menu of the tab it sits in (dropdowns directly inside a tab)
|
|
6
|
+
// 'sidebar' the top of the sidebar: products inside a tab or a dropdown,
|
|
7
|
+
// like Mintlify's - they pick what the sidebar shows
|
|
8
|
+
// 'topbar' a switcher next to the site name (everything else)
|
|
9
|
+
//
|
|
10
|
+
// Without a sidebar on the page (`mode: custom` / `blank`), a 'sidebar'
|
|
11
|
+
// switcher goes to the top bar instead. The mobile menu lists every level
|
|
12
|
+
// either way.
|
|
13
|
+
|
|
14
|
+
export function selectorPlacements(selectors, { sidebar = true } = {}) {
|
|
15
|
+
return selectors.map((sel, i) => {
|
|
16
|
+
const parent = selectors[i - 1]?.kind;
|
|
17
|
+
if (sel.kind === 'tab') return 'tabs';
|
|
18
|
+
if (sel.kind === 'dropdown' && parent === 'tab') return 'tab-menu';
|
|
19
|
+
if (sel.kind === 'product' && (parent === 'tab' || parent === 'dropdown')) return sidebar ? 'sidebar' : 'topbar';
|
|
20
|
+
return 'topbar';
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether a page gets the mobile menu (its button in the top bar, and the
|
|
25
|
+
* panel - TopBar.astro and MobileMenu.astro both ask this). On a phone the
|
|
26
|
+
* top bar hides the tabs and switchers, so any page with navigation to
|
|
27
|
+
* reach needs it - with a sidebar or without (`mode: custom`). */
|
|
28
|
+
export function hasMobileMenu({ sidebar, selectors = [], globalDropdowns = [] }) {
|
|
29
|
+
return sidebar || selectors.length > 0 || globalDropdowns.length > 0;
|
|
30
|
+
}
|
|
@@ -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}`;
|