@writedocs/generator 0.6.0 → 0.7.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/bin/writedocs.js CHANGED
@@ -238,20 +238,27 @@ program
238
238
  program
239
239
  .command('convert')
240
240
  .description("Convert another docs tool's config into writedocs.json")
241
- .argument('[dir]', 'project directory (contains docs.json)', '.')
241
+ .argument('[dir]', 'project directory (contains docs.json or config.json)', '.')
242
242
  .option('--mintlify', "convert a Mintlify project's docs.json")
243
243
  .option('--docs.json', 'same as --mintlify')
244
+ .option('--writedocs', "convert a project from the previous writedocs (its config.json)")
245
+ .option('--config.json', 'same as --writedocs')
244
246
  .option('--force', 'overwrite an existing writedocs.json')
245
247
  .option('--dry-run', 'print the converted writedocs.json instead of writing it')
246
248
  .action(async (dir, options) => {
247
- // --mintlify is the only source today; the flag is still required so
248
- // the command reads the same once other tools are added.
249
- if (!options.mintlify && !options.docsJson) {
250
- log.error('Say what to convert from: writedocs convert --mintlify [dir]');
249
+ const mintlify = options.mintlify || options.docsJson;
250
+ const legacy = options.writedocs || options.configJson;
251
+ if (mintlify === legacy) {
252
+ log.error(
253
+ mintlify
254
+ ? 'Pick one: --mintlify or --writedocs.'
255
+ : 'Say what to convert from: writedocs convert --mintlify [dir], or --writedocs for the previous writedocs.'
256
+ );
251
257
  throw new CliExit(1);
252
258
  }
253
259
  const { runConvert } = await import('../src/cli/convert.js');
254
260
  await runConvert({
261
+ source: legacy ? 'writedocs' : 'mintlify',
255
262
  contentDir: path.resolve(process.cwd(), dir),
256
263
  force: Boolean(options.force),
257
264
  dryRun: Boolean(options.dryRun),
package/package.json CHANGED
@@ -1,88 +1,91 @@
1
- {
2
- "name": "@writedocs/generator",
3
- "version": "0.6.0",
4
- "description": "Static site generator for docs — a writedocs.json + MDX folder in, a static site out.",
5
- "type": "module",
6
- "bin": {
7
- "writedocs": "./bin/writedocs.js"
8
- },
9
- "exports": {
10
- "./components": "./src/components/index.ts",
11
- "./config-schema": "./src/lib/config-schema.js"
12
- },
13
- "files": [
14
- "bin",
15
- "src",
16
- "astro.config.mjs"
17
- ],
18
- "scripts": {
19
- "dev": "node bin/writedocs.js dev",
20
- "build": "node bin/writedocs.js build",
21
- "init": "node bin/writedocs.js init",
22
- "build:schema": "esbuild src/lib/config-schema.ts --format=esm --target=node22 --outfile=src/lib/config-schema.js",
23
- "prepare": "npm run build:schema",
24
- "test": "node --test \"scripts/*.test.js\""
25
- },
26
- "keywords": [
27
- "docs",
28
- "documentation",
29
- "static-site-generator",
30
- "mdx",
31
- "astro"
32
- ],
33
- "homepage": "https://github.com/writedocs/writedocs#readme",
34
- "repository": {
35
- "type": "git",
36
- "url": "git+https://github.com/writedocs/writedocs.git"
37
- },
38
- "bugs": {
39
- "url": "https://github.com/writedocs/writedocs/issues"
40
- },
41
- "author": "Gabriel Raeder",
42
- "license": "ISC",
43
- "publishConfig": {
44
- "access": "public"
45
- },
46
- "dependencies": {
47
- "@apidevtools/swagger-parser": "^12.1.0",
48
- "@astrojs/markdown-remark": "7.2.4",
49
- "@astrojs/mdx": "^7.0.5",
50
- "@astrojs/react": "^6.0.2",
51
- "@astrojs/sitemap": "^3.7.3",
52
- "@iconify-json/bi": "^1.2.7",
53
- "@iconify-json/fa6-brands": "^1.2.6",
54
- "@iconify-json/fa6-solid": "^1.2.4",
55
- "@iconify-json/heroicons": "^1.2.3",
56
- "@iconify-json/ion": "^1.2.7",
57
- "@iconify-json/lucide": "^1.2.116",
58
- "@iconify-json/mdi": "^1.2.3",
59
- "@iconify-json/ri": "^1.2.10",
60
- "@iconify-json/simple-icons": "^1.2.89",
61
- "@iconify-json/tabler": "^1.2.35",
62
- "@mdx-js/mdx": "^3.1.1",
63
- "@shikijs/transformers": "^4.3.1",
64
- "@tailwindcss/vite": "^4.3.2",
65
- "astro": "7.2.9",
66
- "astro-icon": "^1.1.5",
67
- "commander": "^15.0.0",
68
- "dotenv": "^17.4.2",
69
- "gray-matter": "^4.0.3",
70
- "jsonc-parser": "3.3.1",
71
- "katex": "^0.16.47",
72
- "mermaid": "^11.16.0",
73
- "pagefind": "^1.5.2",
74
- "react": "^19.2.8",
75
- "react-dom": "^19.2.8",
76
- "rehype-katex": "^7.0.1",
77
- "remark-math": "^6.0.0",
78
- "shiki": "^4.3.1",
79
- "tailwindcss": "^4.3.2",
80
- "zod": "^4.4.3"
81
- },
82
- "engines": {
83
- "node": ">=22.12.0"
84
- },
85
- "devDependencies": {
86
- "esbuild": "0.28.2"
87
- }
1
+ {
2
+ "name": "@writedocs/generator",
3
+ "version": "0.7.0",
4
+ "description": "Static site generator for docs — a writedocs.json + MDX folder in, a static site out.",
5
+ "type": "module",
6
+ "bin": {
7
+ "writedocs": "./bin/writedocs.js"
8
+ },
9
+ "exports": {
10
+ "./components": "./src/components/index.ts",
11
+ "./config-schema": "./src/lib/config-schema.js",
12
+ "./writedocs.schema.json": "./writedocs.schema.json"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "src",
17
+ "astro.config.mjs",
18
+ "writedocs.schema.json"
19
+ ],
20
+ "scripts": {
21
+ "dev": "node bin/writedocs.js dev",
22
+ "build": "node bin/writedocs.js build",
23
+ "init": "node bin/writedocs.js init",
24
+ "build:schema": "esbuild src/lib/config-schema.ts --format=esm --target=node22 --outfile=src/lib/config-schema.js",
25
+ "prepare": "npm run build:schema && npm run build:json-schema",
26
+ "test": "node --test \"scripts/*.test.js\"",
27
+ "build:json-schema": "node scripts/build-json-schema.mjs"
28
+ },
29
+ "keywords": [
30
+ "docs",
31
+ "documentation",
32
+ "static-site-generator",
33
+ "mdx",
34
+ "astro"
35
+ ],
36
+ "homepage": "https://github.com/writedocs/writedocs#readme",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/writedocs/writedocs.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/writedocs/writedocs/issues"
43
+ },
44
+ "author": "Gabriel Raeder",
45
+ "license": "ISC",
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "dependencies": {
50
+ "@apidevtools/swagger-parser": "^12.1.0",
51
+ "@astrojs/markdown-remark": "7.2.4",
52
+ "@astrojs/mdx": "^7.0.5",
53
+ "@astrojs/react": "^6.0.2",
54
+ "@astrojs/sitemap": "^3.7.3",
55
+ "@iconify-json/bi": "^1.2.7",
56
+ "@iconify-json/fa6-brands": "^1.2.6",
57
+ "@iconify-json/fa6-solid": "^1.2.4",
58
+ "@iconify-json/heroicons": "^1.2.3",
59
+ "@iconify-json/ion": "^1.2.7",
60
+ "@iconify-json/lucide": "^1.2.116",
61
+ "@iconify-json/mdi": "^1.2.3",
62
+ "@iconify-json/ri": "^1.2.10",
63
+ "@iconify-json/simple-icons": "^1.2.89",
64
+ "@iconify-json/tabler": "^1.2.35",
65
+ "@mdx-js/mdx": "^3.1.1",
66
+ "@shikijs/transformers": "^4.3.1",
67
+ "@tailwindcss/vite": "^4.3.2",
68
+ "astro": "7.2.9",
69
+ "astro-icon": "^1.1.5",
70
+ "commander": "^15.0.0",
71
+ "dotenv": "^17.4.2",
72
+ "gray-matter": "^4.0.3",
73
+ "jsonc-parser": "3.3.1",
74
+ "katex": "^0.16.47",
75
+ "mermaid": "^11.16.0",
76
+ "pagefind": "^1.5.2",
77
+ "react": "^19.2.8",
78
+ "react-dom": "^19.2.8",
79
+ "rehype-katex": "^7.0.1",
80
+ "remark-math": "^6.0.0",
81
+ "shiki": "^4.3.1",
82
+ "tailwindcss": "^4.3.2",
83
+ "zod": "^4.4.3"
84
+ },
85
+ "engines": {
86
+ "node": ">=22.12.0"
87
+ },
88
+ "devDependencies": {
89
+ "esbuild": "0.28.2"
90
+ }
88
91
  }
@@ -1,24 +1,48 @@
1
- // `writedocs convert --mintlify [dir]` - turns a Mintlify project's
2
- // docs.json into writedocs.json, in the same folder, then checks the pages
3
- // the same way `writedocs validate` does. The conversion itself is
4
- // lib/mintlify-convert.js; this file is the CLI around it.
1
+ // `writedocs convert --mintlify [dir]` / `--writedocs [dir]` - turns a
2
+ // Mintlify project's docs.json, or a config.json from the previous writedocs,
3
+ // into writedocs.json, in the same folder, then checks the pages the same way
4
+ // `writedocs validate` does. The conversions themselves are
5
+ // lib/mintlify-convert.js and lib/writedocs-legacy-convert.js; this file is
6
+ // the CLI around them.
5
7
  import fs from 'node:fs';
6
8
  import path from 'node:path';
7
9
  import { loadMintlifyConfig, convertMintlifyConfig, formatNotes } from '../lib/mintlify-convert.js';
10
+ import { loadLegacyConfig, convertLegacyConfig } from '../lib/writedocs-legacy-convert.js';
8
11
  import { validateDocsConfig, formatValidationIssuesDetailed } from '../lib/config-schema.js';
9
12
  import { checkContent, formatContentIssues } from '../lib/content-check.js';
10
13
  import { log, step, plural, color, displayPath, CliExit } from './output.js';
11
14
 
12
- export async function runConvert({ contentDir, force = false, dryRun = false }) {
13
- const docsJsonPath = path.join(contentDir, 'docs.json');
14
- if (!fs.existsSync(docsJsonPath)) {
15
- const legacy = path.join(contentDir, 'mint.json');
16
- if (fs.existsSync(legacy)) {
17
- log.error("Found mint.json, Mintlify's older config format.");
18
- log.detail(color.dim(`Run "npx mint upgrade" in ${contentDir} to turn it into docs.json, then run this again.`));
19
- } else {
20
- log.error(`No docs.json found in ${contentDir}`);
21
- }
15
+ const SOURCES = {
16
+ mintlify: {
17
+ file: 'docs.json',
18
+ load: loadMintlifyConfig,
19
+ convert: async (input) => convertMintlifyConfig(input),
20
+ missing(contentDir) {
21
+ if (fs.existsSync(path.join(contentDir, 'mint.json'))) {
22
+ log.error("Found mint.json, Mintlify's older config format.");
23
+ log.detail(color.dim(`Run "npx mint upgrade" in ${contentDir} to turn it into docs.json, then run this again.`));
24
+ } else {
25
+ log.error(`No docs.json found in ${contentDir}`);
26
+ }
27
+ },
28
+ domainHint: '(Mintlify sets this in its dashboard, not docs.json.)',
29
+ },
30
+ writedocs: {
31
+ file: 'config.json',
32
+ load: loadLegacyConfig,
33
+ convert: (input, contentDir) => convertLegacyConfig(input, contentDir),
34
+ missing(contentDir) {
35
+ log.error(`No config.json found in ${contentDir}`);
36
+ },
37
+ domainHint: '(config.json had no field for it.)',
38
+ },
39
+ };
40
+
41
+ export async function runConvert({ source = 'mintlify', contentDir, force = false, dryRun = false }) {
42
+ const from = SOURCES[source];
43
+ const inputPath = path.join(contentDir, from.file);
44
+ if (!fs.existsSync(inputPath)) {
45
+ from.missing(contentDir);
22
46
  throw new CliExit(1);
23
47
  }
24
48
  const outPath = path.join(contentDir, 'writedocs.json');
@@ -28,15 +52,15 @@ export async function runConvert({ contentDir, force = false, dryRun = false })
28
52
  throw new CliExit(1);
29
53
  }
30
54
 
31
- let docs;
55
+ let input;
32
56
  try {
33
- docs = loadMintlifyConfig(docsJsonPath);
57
+ input = from.load(inputPath);
34
58
  } catch (err) {
35
- log.error(`Couldn't read ${displayPath(docsJsonPath)}: ${err.message}`);
59
+ log.error(`Couldn't read ${displayPath(inputPath)}: ${err.message}`);
36
60
  throw new CliExit(1);
37
61
  }
38
62
 
39
- const { config, notes } = convertMintlifyConfig(docs);
63
+ const { config, notes } = await from.convert(input, contentDir);
40
64
  const text = `${JSON.stringify(config, null, 2)}\n`;
41
65
 
42
66
  // The result must pass writedocs' own schema - if it doesn't, that's a
@@ -54,7 +78,7 @@ export async function runConvert({ contentDir, force = false, dryRun = false })
54
78
  log.line(text);
55
79
  } else {
56
80
  fs.writeFileSync(outPath, text);
57
- log.success(`Converted ${path.basename(docsJsonPath)} to ${color.bold(displayPath(outPath))}`);
81
+ log.success(`Converted ${from.file} to ${color.bold(displayPath(outPath))}`);
58
82
  }
59
83
 
60
84
  if (notes.length) {
@@ -87,7 +111,7 @@ export async function runConvert({ contentDir, force = false, dryRun = false })
87
111
  else log.success(summary);
88
112
  if (!config.domain) {
89
113
  log.info(
90
- 'Next: set "domain" in writedocs.json to your site\'s URL - it turns on sitemap.xml and absolute links for social previews. (Mintlify sets this in its dashboard, not docs.json.)'
114
+ `Next: set "domain" in writedocs.json to your site's URL - it turns on sitemap.xml and absolute links for social previews. ${from.domainHint}`
91
115
  );
92
116
  }
93
117
  }
@@ -168,6 +168,28 @@ function buildOperations(spec) {
168
168
  return operations;
169
169
  }
170
170
 
171
+ /** An operation as JSON. A recursive schema - a tree whose nodes contain
172
+ * nodes - dereferences into a cycle (SwaggerParser.dereference() resolves
173
+ * the $ref to the very same object), which JSON can't hold and the API
174
+ * pages couldn't render anyway. Where a schema would contain itself, a
175
+ * short stand-in takes its place: its type and title, marked
176
+ * x-circular. Shared, non-recursive schemas are written in full each time,
177
+ * as before. */
178
+ function operationJson(operation) {
179
+ const ancestors = [];
180
+ const cut = (node) => {
181
+ if (node === null || typeof node !== 'object') return node;
182
+ if (ancestors.includes(node)) {
183
+ return { ...(node.type ? { type: node.type } : {}), ...(node.title ? { title: node.title } : {}), 'x-circular': true };
184
+ }
185
+ ancestors.push(node);
186
+ const out = Array.isArray(node) ? node.map(cut) : Object.fromEntries(Object.entries(node).map(([k, v]) => [k, cut(v)]));
187
+ ancestors.pop();
188
+ return out;
189
+ };
190
+ return JSON.stringify(cut(operation), null, 2);
191
+ }
192
+
171
193
  function rmrf(dir) {
172
194
  fs.rmSync(dir, { recursive: true, force: true });
173
195
  }
@@ -233,7 +255,7 @@ async function generateApiPagesForGroup({ contentDir, generatedDocsDir, group, o
233
255
  manifest.push({ slug, method: operation.method, path: operation.path, tags: operation.tags, title, generated });
234
256
 
235
257
  const opFile = path.join(openapiOutDir, 'operations', operationFileName(operation.method, operation.path));
236
- fs.writeFileSync(opFile, JSON.stringify(operation, null, 2));
258
+ fs.writeFileSync(opFile, operationJson(operation));
237
259
  }
238
260
 
239
261
  fs.writeFileSync(path.join(openapiOutDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
@@ -355,7 +377,7 @@ async function parsePageSpecs({ contentDir, openapiOutDir, groupSpecs, summary }
355
377
  const outDir = path.join(openapiOutDir, '_pages', specDirName(spec), 'operations');
356
378
  fs.mkdirSync(outDir, { recursive: true });
357
379
  for (const operation of operations) {
358
- fs.writeFileSync(path.join(outDir, operationFileName(operation.method, operation.path)), JSON.stringify(operation, null, 2));
380
+ fs.writeFileSync(path.join(outDir, operationFileName(operation.method, operation.path)), operationJson(operation));
359
381
  }
360
382
  summary.pageSpecs.push({ spec, operations: operations.length });
361
383
  }
@@ -10,6 +10,8 @@
10
10
  // - writedocs.json's navigation lists a page that doesn't exist
11
11
  // - a redirect is a pattern (`/old/:slug`) rather than one exact path
12
12
  // - a Markdown image with a relative path names a file that doesn't exist
13
+ // - an import the build can't resolve: a Docusaurus path (@site/...), or a
14
+ // relative or /snippets/ import of a file that isn't there
13
15
  // - an OpenAPI group's spec is missing or doesn't parse, or two groups
14
16
  // share one `openapi.path`
15
17
  // Warnings - the build succeeds, but not as written:
@@ -129,6 +131,45 @@ async function checkPage(contentDir, rel, errors, warnings, openapiRefs) {
129
131
  );
130
132
  return;
131
133
  }
134
+ // Imports the build can't resolve fail it: Docusaurus' path aliases
135
+ // (common in pages from the previous writedocs), and a relative or
136
+ // /snippets/ import of a file that isn't there. Package imports aren't
137
+ // checked - whether they resolve depends on what's installed.
138
+ visit(tree, 'mdxjsEsm', (node) => {
139
+ const code = String(node.value ?? '');
140
+ for (const m of code.matchAll(/\b(?:import|export)\b[^'";]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]/g)) {
141
+ const spec = m[1] ?? m[2];
142
+ const at = node.position?.start?.line ? node.position.start.line + lineOffset + code.slice(0, m.index).split('\n').length - 1 : undefined;
143
+ if (/^@(site|theme|docusaurus|generated)(\/|$)/.test(spec)) {
144
+ errors.push(
145
+ issue(
146
+ rel,
147
+ at,
148
+ `Imports "${spec}", a Docusaurus path - the build fails on it.`,
149
+ /^@site\/src\/components\/?$/.test(spec)
150
+ ? "writedocs' components (Card, Callout, Tabs, Accordion, ...) need no import - remove this line."
151
+ : spec.startsWith('@site/src/components/')
152
+ ? 'A custom component of the old site - move it into a snippet (snippets/), or remove it and what uses it.'
153
+ : 'Remove the import, and what uses it.'
154
+ )
155
+ );
156
+ continue;
157
+ }
158
+ let target = null;
159
+ if (spec.startsWith('./') || spec.startsWith('../')) target = path.resolve(path.dirname(path.join(contentDir, rel)), spec);
160
+ else if (spec.startsWith('/snippets/')) target = path.join(contentDir, spec);
161
+ if (!target) continue;
162
+ const exists = ['', '.mdx', '.md', '.js', '.jsx', '.ts', '.tsx', '.json'].some((ext) => {
163
+ try {
164
+ return fs.statSync(target + ext).isFile();
165
+ } catch {
166
+ return false;
167
+ }
168
+ });
169
+ if (!exists) errors.push(issue(rel, at, `Imports ${spec}, which doesn't exist - the build fails on it.`));
170
+ }
171
+ });
172
+
132
173
  // A Markdown image with a relative path is imported by the build (Astro's
133
174
  // image pipeline), so a missing file fails the whole build.
134
175
  visit(tree, 'image', (node) => {
@@ -0,0 +1,204 @@
1
+ // The editor help text for writedocs.schema.json (see lib/json-schema.js):
2
+ // one entry per field of writedocs.json, keyed by its path - `a.b.c`, with
3
+ // `[]` for an array's items. Fields inside the navigation's recursive types
4
+ // are keyed by the type's name instead (`Tab.icon`, `NavigationItem.group`).
5
+ //
6
+ // scripts/json-schema.test.js fails when a field has no entry here, or an
7
+ // entry names a field that doesn't exist - so a new field in
8
+ // config-schema.ts needs its description added here too.
9
+
10
+ const CHILDREN = {
11
+ pages: 'The pages and groups in this section of the sidebar.',
12
+ tabs: 'Tabs inside this one.',
13
+ versions: 'Versions inside this one - a version picker in the sidebar.',
14
+ languages: 'Languages inside this one - a language picker.',
15
+ dropdowns: 'Dropdowns inside this one - a menu of sections in the sidebar.',
16
+ products: 'Products inside this one - a product picker.',
17
+ href: 'Makes this a link to another address instead of a section with pages of its own.',
18
+ };
19
+
20
+ const CONTAINERS = {
21
+ Tab: {
22
+ tab: 'The tab\'s name, shown in the topbar.',
23
+ icon: 'Icon next to the name: a Lucide name ("book-open"), "collection:name" ("mdi:server"), an emoji, or an image path.',
24
+ },
25
+ Version: {
26
+ version: 'The version\'s name, like "v2".',
27
+ label: 'Text shown in the version picker instead of `version`.',
28
+ tag: 'A short badge next to the version, like "Latest" or "Deprecated".',
29
+ default: 'Open this version first. Without it, the first version is the default.',
30
+ },
31
+ Language: {
32
+ language: 'The language code, like "en" or "pt-BR".',
33
+ label: 'Text shown in the language picker instead of `language`, like "Português".',
34
+ },
35
+ Dropdown: {
36
+ dropdown: 'The dropdown\'s name.',
37
+ icon: 'Icon next to the name: a Lucide name, "collection:name", an emoji, or an image path.',
38
+ },
39
+ Product: {
40
+ product: 'The product\'s name, shown in the product picker.',
41
+ icon: 'Icon next to the name: a Lucide name, "collection:name", an emoji, or an image path.',
42
+ description: 'One line under the name in the product picker.',
43
+ },
44
+ };
45
+
46
+ const containerEntries = Object.entries(CONTAINERS).flatMap(([type, fields]) => [
47
+ ...Object.entries(fields).map(([field, text]) => [`${type}.${field}`, text]),
48
+ ...Object.entries(CHILDREN).map(([field, text]) => [`${type}.${field}`, text]),
49
+ ]);
50
+
51
+ const logo = (where) => ({
52
+ [where]: 'The logo: one image path for both color modes, or `{ light, dark, label }` with an image per mode.',
53
+ [`${where}.light`]: 'Logo image shown in light mode.',
54
+ [`${where}.dark`]: 'Logo image shown in dark mode.',
55
+ [`${where}.label`]: 'Text shown next to the logo. No text is shown by default - a logo is usually already a wordmark.',
56
+ });
57
+
58
+ const font = (where, what) => ({
59
+ [`${where}.family`]: `Font family for ${what}. A Google Fonts name loads automatically; add \`source\` for a font file instead.`,
60
+ [`${where}.weight`]: `Font weight for ${what}, like 400 or 700.`,
61
+ [`${where}.source`]: 'Path or URL of a font file to load, instead of Google Fonts.',
62
+ [`${where}.format`]: 'Format of the `source` font file.',
63
+ });
64
+
65
+ const link = (where) => ({
66
+ [`${where}.label`]: 'Link text. A link needs a label, an icon, or both.',
67
+ [`${where}.href`]: 'Where the link goes - a site path like "/docs/intro/" or a full URL.',
68
+ [`${where}.icon`]: 'Icon shown with the link: a Lucide name, "collection:name", an emoji, or an image path.',
69
+ });
70
+
71
+ const script = (where) => ({
72
+ [`${where}.src`]: 'URL of the script to load. Use either `src` or `content`, not both.',
73
+ [`${where}.content`]: 'The script\'s code, inline. Use either `src` or `content`, not both.',
74
+ });
75
+
76
+ export const ROOT_DESCRIPTION = 'Configuration of a writedocs site: its name, look, navigation and integrations.';
77
+
78
+ export const TYPE_DESCRIPTIONS = {
79
+ NavigationItem: 'A page (its path from the project folder, without the extension), a group of pages, or a link.',
80
+ Tab: 'A tab in the topbar, with its own sidebar.',
81
+ Version: 'A version of the docs, chosen from a version picker.',
82
+ Language: 'A language of the docs, chosen from a language picker.',
83
+ Dropdown: 'A section chosen from a dropdown menu.',
84
+ Product: 'A product, chosen from a product picker.',
85
+ };
86
+
87
+ export const DESCRIPTIONS = {
88
+ $schema: 'The JSON Schema this file follows - for editor autocompletion and checks.',
89
+ name: 'Required. The site\'s name - shown in the browser tab ("Page title · name") and as the topbar text when there\'s no logo.',
90
+ description: 'Description used for pages that don\'t set their own `description` in frontmatter.',
91
+
92
+ styles: 'Colors, logo, favicon, fonts, code block themes, navbar and background.',
93
+ 'styles.colors': 'The site\'s colors.',
94
+ 'styles.colors.primary': 'Main color: links, buttons, the active page in the sidebar. Default "#6366f1".',
95
+ 'styles.colors.text': 'Body text color in light mode. Default "#0f172a".',
96
+ 'styles.colors.dark': 'Dark-mode colors. Anything left out uses the light-mode value.',
97
+ 'styles.colors.dark.primary': 'Main color in dark mode. Pick a lighter one if `primary` is dark - links use it on a dark background.',
98
+ 'styles.colors.dark.text': 'Body text color in dark mode. Default "#e2e8f0".',
99
+ ...logo('styles.logo'),
100
+ 'styles.favicon': 'Favicon image path.',
101
+ 'styles.fonts': 'Fonts. Default: Inter. `heading` and `body` override just that part; anything they leave out comes from the top-level fields.',
102
+ ...font('styles.fonts', 'all text'),
103
+ 'styles.fonts.heading': 'Font for headings only.',
104
+ ...font('styles.fonts.heading', 'headings'),
105
+ 'styles.fonts.body': 'Font for body text only.',
106
+ ...font('styles.fonts.body', 'body text'),
107
+ 'styles.codeblocks': 'Syntax-highlighting themes for code blocks and the API playground.',
108
+ 'styles.codeblocks.light': 'Shiki theme name for light mode (https://shiki.style/themes). Default "github-light".',
109
+ 'styles.codeblocks.dark': 'Shiki theme name for dark mode. Default "github-dark".',
110
+ 'styles.codeblocks.langAlias': 'Extra language names for code blocks, mapped to a language Shiki knows - like { "curl": "bash" }.',
111
+ 'styles.navbar': 'The topbar\'s own background. Without it, the topbar matches the page background. Its text turns black or white automatically.',
112
+ 'styles.navbar.light': 'Topbar in light mode: a background color, or `{ background, accent }`.',
113
+ 'styles.navbar.light.background': 'Topbar background color in light mode.',
114
+ 'styles.navbar.light.accent': 'Color of the active tab and hover states in the topbar, in place of `styles.colors.primary`.',
115
+ 'styles.navbar.dark': 'Topbar in dark mode: a background color, or `{ background, accent }`.',
116
+ 'styles.navbar.dark.background': 'Topbar background color in dark mode.',
117
+ 'styles.navbar.dark.accent': 'Color of the active tab and hover states in the topbar, in dark mode.',
118
+ 'styles.background': 'Background color of the site, and an optional background image.',
119
+ 'styles.background.colors': 'Background colors.',
120
+ 'styles.background.colors.light': 'Background color in light mode. Default "#ffffff".',
121
+ 'styles.background.colors.dark': 'Background color in dark mode. Default "#0b1120".',
122
+ 'styles.background.images': 'Background images, shown behind the content and sidebar.',
123
+ 'styles.background.images.light': 'Background image in light mode.',
124
+ 'styles.background.images.dark': 'Background image in dark mode.',
125
+
126
+ navigation:
127
+ 'Required. The site\'s structure: a list of pages and groups, or an object with exactly one of `tabs`, `versions`, `languages`, `dropdowns` or `products`. Those can nest inside each other.',
128
+ 'navigation.global': 'Dropdowns shown on every page, whatever section is open.',
129
+ 'navigation.global.dropdowns': 'Dropdowns shown on every page.',
130
+ 'navigation.tabs': 'Tabs in the topbar, each with its own sidebar.',
131
+ 'navigation.versions': 'Versions of the docs, with a version picker.',
132
+ 'navigation.languages': 'Languages of the docs, with a language picker.',
133
+ 'navigation.dropdowns': 'Sections chosen from a dropdown menu.',
134
+ 'navigation.products': 'Products, with a product picker.',
135
+
136
+ 'NavigationItem.group': 'The group\'s name in the sidebar.',
137
+ 'NavigationItem.page': 'A page the group\'s name links to.',
138
+ 'NavigationItem.pages': 'The pages and groups inside this group.',
139
+ 'NavigationItem.openapi': 'Generate this group\'s pages from an OpenAPI spec - a page per operation, grouped by tag.',
140
+ 'NavigationItem.openapi.src': 'Path of the OpenAPI spec file, relative to writedocs.json.',
141
+ 'NavigationItem.openapi.path': 'URL path the generated pages go under, like "/api". Each OpenAPI group needs its own.',
142
+ 'NavigationItem.label': 'Link text in the sidebar.',
143
+ 'NavigationItem.href': 'Where the link goes.',
144
+ ...Object.fromEntries(containerEntries),
145
+
146
+ socials: 'Icon links in the footer: platform -> URL, like { "github": "https://github.com/acme" }. The key is also the icon.',
147
+ topbar: 'The topbar.',
148
+ 'topbar.links': 'Links at the right of the topbar.',
149
+ ...link('topbar.links[]'),
150
+ footer: 'The footer: columns of links, and the `socials` icons.',
151
+ 'footer.columns': 'Columns of links.',
152
+ 'footer.columns[].title': 'The column\'s heading. Optional.',
153
+ 'footer.columns[].links': 'The links in the column.',
154
+ ...link('footer.columns[].links[]'),
155
+ ...logo('footer.logo'),
156
+
157
+ api: 'The API playground.',
158
+ 'api.proxy': 'Send "Try it" requests through writedocs\' proxy, for APIs that don\'t allow calls from other sites (CORS). Default true.',
159
+ domain: 'The site\'s address, like "docs.example.com". Turns on sitemap.xml and absolute URLs in link previews.',
160
+
161
+ seo: 'Default metadata for every page. A page\'s own `seo` frontmatter overrides it field by field.',
162
+ 'seo.ogImage': 'Image shown in link previews - a path or a full URL.',
163
+ 'seo.ogType': 'The og:type of pages. Default "website".',
164
+ 'seo.twitterCard': 'X/Twitter card style. Default "summary_large_image" with an `ogImage`, "summary" without.',
165
+ 'seo.keywords': 'Keywords for the <meta name="keywords"> tag.',
166
+ 'seo.noindex': 'Ask search engines not to index pages, and leave them out of sitemap.xml.',
167
+ contextMenu: 'Adds a "Copy page" menu to pages - copy as Markdown, or open the page in an AI assistant - and a Markdown copy of each page at its address + ".md".',
168
+ 'contextMenu.openIn': 'Which AI assistants the menu offers. Default: all three.',
169
+ redirects: 'Redirects from old addresses. Each matches one exact path.',
170
+ 'redirects[].source': 'The old path, like "/old-page".',
171
+ 'redirects[].destination': 'Where to send it, like "/docs/new-page/".',
172
+ variables: 'Values pages can insert with [[name]], like { "productName": "Acme" }.',
173
+ banner: 'A banner above the topbar on every page.',
174
+ 'banner.content': 'The banner\'s text. Markdown links work.',
175
+ 'banner.dismissible': 'Let readers close it. Default false.',
176
+ 'banner.type': 'Its color: "info", "warning" or "critical". Default "info".',
177
+ notFound: 'The 404 page.',
178
+ 'notFound.title': 'The 404 page\'s title.',
179
+ 'notFound.description': 'Text under the title on the 404 page.',
180
+ scripts: 'Scripts added to every page - for tools `integrations` doesn\'t cover.',
181
+ 'scripts.head': 'Scripts added at the end of <head>.',
182
+ ...script('scripts.head[]'),
183
+ 'scripts.body': 'Scripts added at the end of <body>.',
184
+ ...script('scripts.body[]'),
185
+
186
+ integrations: 'Analytics and chat tools.',
187
+ 'integrations.ga4': 'Google Analytics 4.',
188
+ 'integrations.ga4.measurementId': 'Measurement ID, like "G-XXXXXXXXXX".',
189
+ 'integrations.googleTagManager': 'Google Tag Manager.',
190
+ 'integrations.googleTagManager.containerId': 'Container ID, like "GTM-XXXXXXX".',
191
+ 'integrations.plausible': 'Plausible Analytics.',
192
+ 'integrations.plausible.domain': 'The site\'s domain, as registered in Plausible.',
193
+ 'integrations.plausible.src': 'Script URL, for a self-hosted Plausible. Default "https://plausible.io/js/script.js".',
194
+ 'integrations.fathom': 'Fathom Analytics.',
195
+ 'integrations.fathom.siteId': 'Fathom\'s site ID, like "ABCDEFGH".',
196
+ 'integrations.posthog': 'PostHog.',
197
+ 'integrations.posthog.apiKey': 'Project API key, starting with "phc_".',
198
+ 'integrations.posthog.apiHost': 'API host. Default "https://us.i.posthog.com" - use yours for EU cloud or self-hosting.',
199
+ 'integrations.umami': 'Umami Analytics.',
200
+ 'integrations.umami.websiteId': 'Umami\'s website ID.',
201
+ 'integrations.umami.src': 'Script URL. Default "https://cloud.umami.is/script.js" - use yours when self-hosting.',
202
+ 'integrations.askAi': 'AI chat over the docs, with DocsBot.',
203
+ 'integrations.askAi.id': 'DocsBot\'s "teamId/botId". The WRITEDOCS_ASK_AI_ID environment variable overrides it.',
204
+ };