@writedocs/generator 0.5.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
@@ -205,23 +205,60 @@ program
205
205
  throw new CliExit(1);
206
206
  });
207
207
 
208
+ program
209
+ // Same shape as `broken-links`: reads the project, exits 0 (nothing
210
+ // found) or 1.
211
+ .command('a11y')
212
+ .description('Check accessibility - color contrast, image alt text, headings, link text')
213
+ .argument('[dir]', 'content directory (contains writedocs.json)', '.')
214
+ .action(async (dir) => {
215
+ const contentDir = path.resolve(process.cwd(), dir);
216
+ const { preflightCheck } = await import('../src/cli/preflight.js');
217
+ preflightCheck(contentDir);
218
+ const configText = fs.readFileSync(path.join(contentDir, 'writedocs.json'), 'utf-8');
219
+ const checking = step('Checking accessibility');
220
+ const { checkAccessibility } = await import('../src/lib/a11y-check.js');
221
+ const { formatContentIssues } = await import('../src/lib/content-check.js');
222
+ const result = await checkAccessibility(contentDir, configText);
223
+ checking.stop();
224
+
225
+ if (result.issues.length === 0) {
226
+ log.success(`No accessibility issues - checked writedocs.json and ${plural(result.pages, 'page')}`);
227
+ return;
228
+ }
229
+ const count = plural(result.issues.length, 'accessibility issue');
230
+ log.error(count);
231
+ log.line();
232
+ log.line(formatContentIssues(result.issues));
233
+ log.line();
234
+ log.error(`${count} - checked writedocs.json and ${plural(result.pages, 'page')}`);
235
+ throw new CliExit(1);
236
+ });
237
+
208
238
  program
209
239
  .command('convert')
210
240
  .description("Convert another docs tool's config into writedocs.json")
211
- .argument('[dir]', 'project directory (contains docs.json)', '.')
241
+ .argument('[dir]', 'project directory (contains docs.json or config.json)', '.')
212
242
  .option('--mintlify', "convert a Mintlify project's docs.json")
213
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')
214
246
  .option('--force', 'overwrite an existing writedocs.json')
215
247
  .option('--dry-run', 'print the converted writedocs.json instead of writing it')
216
248
  .action(async (dir, options) => {
217
- // --mintlify is the only source today; the flag is still required so
218
- // the command reads the same once other tools are added.
219
- if (!options.mintlify && !options.docsJson) {
220
- 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
+ );
221
257
  throw new CliExit(1);
222
258
  }
223
259
  const { runConvert } = await import('../src/cli/convert.js');
224
260
  await runConvert({
261
+ source: legacy ? 'writedocs' : 'mintlify',
225
262
  contentDir: path.resolve(process.cwd(), dir),
226
263
  force: Boolean(options.force),
227
264
  dryRun: Boolean(options.dryRun),
package/package.json CHANGED
@@ -1,88 +1,91 @@
1
- {
2
- "name": "@writedocs/generator",
3
- "version": "0.5.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
  }
@@ -5,8 +5,8 @@ import matter from 'gray-matter';
5
5
  import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
6
6
  import { parseOpenApiRef, openApiOperationKey, specDirName } from '../lib/openapi-ref.js';
7
7
  import { findAllPages } from '../lib/pages.js';
8
+ import { HTTP_METHODS, collectOpenApiGroups } from '../lib/openapi-spec.js';
8
9
 
9
- const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
10
10
 
11
11
  /** Canonical "METHOD /path" key used everywhere an operation needs to be
12
12
  * identified: the manifest, a hand-written page's `openapi:` frontmatter,
@@ -168,63 +168,30 @@ function buildOperations(spec) {
168
168
  return operations;
169
169
  }
170
170
 
171
- function rmrf(dir) {
172
- fs.rmSync(dir, { recursive: true, force: true });
173
- }
174
-
175
- /** Walks writedocs.json's raw `navigation` tree (any shape - a bare array, or
176
- * an object choosing tabs/versions/languages/dropdowns/products, plus
177
- * `global.dropdowns`) looking for group nodes shaped like
178
- * `{ group, openapi: { src, path } }`, however deeply nested inside
179
- * hand-authored groups or containers. Runs directly against the raw
180
- * JSON (before writedocs.json's own zod validation even happens - this CLI
181
- * step runs first, see generateApiPages() below), so it deliberately
182
- * doesn't import anything from lib/config.ts and just duck-types each
183
- * node the same way lib/config.ts's own walkSections()/
184
- * expandOpenApiInContainer() do. */
185
- function collectOpenApiGroups(navigation) {
186
- const found = [];
187
-
188
- function fromPagesItem(item) {
189
- if (!item || typeof item !== 'object') return; // plain page-slug string - not a group
190
- if (item.group && item.openapi) {
191
- found.push(item);
192
- return;
193
- }
194
- if (Array.isArray(item.pages)) {
195
- for (const child of item.pages) fromPagesItem(child);
196
- }
197
- // otherwise a { label, href } link leaf - nothing to collect
198
- }
199
-
200
- function fromContainer(node) {
201
- if (!node || typeof node !== 'object') return;
202
- if (Array.isArray(node.pages)) {
203
- for (const item of node.pages) fromPagesItem(item);
204
- } else if (Array.isArray(node.tabs)) {
205
- for (const t of node.tabs) fromContainer(t);
206
- } else if (Array.isArray(node.versions)) {
207
- for (const v of node.versions) fromContainer(v);
208
- } else if (Array.isArray(node.languages)) {
209
- for (const l of node.languages) fromContainer(l);
210
- } else if (Array.isArray(node.dropdowns)) {
211
- for (const d of node.dropdowns) fromContainer(d);
212
- } else if (Array.isArray(node.products)) {
213
- for (const p of node.products) fromContainer(p);
214
- }
215
- // otherwise a bare { href } container - nothing to collect
216
- }
217
-
218
- if (Array.isArray(navigation)) {
219
- for (const item of navigation) fromPagesItem(item);
220
- } else if (navigation && typeof navigation === 'object') {
221
- if (Array.isArray(navigation.global?.dropdowns)) {
222
- for (const d of navigation.global.dropdowns) fromContainer(d);
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 };
223
184
  }
224
- fromContainer(navigation);
225
- }
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
+ }
226
192
 
227
- return found;
193
+ function rmrf(dir) {
194
+ fs.rmSync(dir, { recursive: true, force: true });
228
195
  }
229
196
 
230
197
  /** Generates every page for one `{ group, openapi: { src, path } }`
@@ -288,7 +255,7 @@ async function generateApiPagesForGroup({ contentDir, generatedDocsDir, group, o
288
255
  manifest.push({ slug, method: operation.method, path: operation.path, tags: operation.tags, title, generated });
289
256
 
290
257
  const opFile = path.join(openapiOutDir, 'operations', operationFileName(operation.method, operation.path));
291
- fs.writeFileSync(opFile, JSON.stringify(operation, null, 2));
258
+ fs.writeFileSync(opFile, operationJson(operation));
292
259
  }
293
260
 
294
261
  fs.writeFileSync(path.join(openapiOutDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
@@ -410,7 +377,7 @@ async function parsePageSpecs({ contentDir, openapiOutDir, groupSpecs, summary }
410
377
  const outDir = path.join(openapiOutDir, '_pages', specDirName(spec), 'operations');
411
378
  fs.mkdirSync(outDir, { recursive: true });
412
379
  for (const operation of operations) {
413
- 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));
414
381
  }
415
382
  summary.pageSpecs.push({ spec, operations: operations.length });
416
383
  }