@writedocs/generator 0.6.0 → 0.7.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/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.1",
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
  }
@@ -220,19 +220,9 @@ const darkNavbarFgMuted = darkNavbarConfigured
220
220
  // design choice, unlike text color above), falling back to the sitewide
221
221
  // --wd-primary (today's exact behavior) when unset - a site that sets a
222
222
  // navbar background but no accent keeps its ordinary brand-colored
223
- // active-tab fill exactly as before.
223
+ // active-tab underline.
224
224
  const lightNavbarAccent = navLight.accent ?? lightPrimary;
225
225
  const darkNavbarAccent = navDark.accent ?? darkPrimary;
226
- // --wd-navbar-accent-text: the active tab's own text color, painted on top
227
- // of --wd-navbar-accent above. Hardcoded to white before this feature
228
- // existed, which only ever looked right because every accent color in
229
- // practice (always --wd-primary until now) happened to be dark/saturated
230
- // enough for white text - contrastTextColor() (lib/config.ts) picks
231
- // black or white by actual luminance instead, so a light `accent` (e.g.
232
- // a pale one against a dark navbar) still gets legible active-tab text
233
- // rather than assuming white always works.
234
- const lightNavbarAccentText = contrastTextColor(lightNavbarAccent);
235
- const darkNavbarAccentText = contrastTextColor(darkNavbarAccent);
236
226
  // --wd-navbar-border: the switcher pills' (version/language/product) own
237
227
  // border. Never the flat --wd-border (a fixed neutral gray/slate tuned for
238
228
  // sitting on the page background) once navbar is configured - that reads
@@ -249,6 +239,14 @@ const lightNavbarBorder = lightNavbarConfigured
249
239
  const darkNavbarBorder = darkNavbarConfigured
250
240
  ? `color-mix(in srgb, ${darkNavbarFg} 25%, transparent)`
251
241
  : 'var(--wd-border)';
242
+ // --wd-navbar-hover: a topbar tab's background on hover. On a navbar with
243
+ // the page's own background, the sidebar's hover tint (NavTree.astro) -
244
+ // the same 6% of --wd-primary. A configured navbar can *be* the primary
245
+ // color, where that tint would vanish, so there it's a light tint of the
246
+ // navbar's own contrast-correct text color instead, like --wd-navbar-border.
247
+ const sidebarHover = 'color-mix(in srgb, var(--wd-primary) 6%, transparent)';
248
+ const lightNavbarHover = lightNavbarConfigured ? `color-mix(in srgb, ${lightNavbarFg} 12%, transparent)` : sidebarHover;
249
+ const darkNavbarHover = darkNavbarConfigured ? `color-mix(in srgb, ${darkNavbarFg} 12%, transparent)` : sidebarHover;
252
250
  // The <body> canvas's own paint - same color as --wd-background above
253
251
  // (there's only one background color to configure now), plus an optional
254
252
  // image layered on top. `images.light`/`.dark` are plain public/-relative
@@ -517,8 +515,8 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
517
515
  wdNavbarFgLight: lightNavbarFg,
518
516
  wdNavbarFgMutedLight: lightNavbarFgMuted,
519
517
  wdNavbarAccentLight: lightNavbarAccent,
520
- wdNavbarAccentTextLight: lightNavbarAccentText,
521
518
  wdNavbarBorderLight: lightNavbarBorder,
519
+ wdNavbarHoverLight: lightNavbarHover,
522
520
  wdPageBgColorLight: lightPageBgColor,
523
521
  wdPageBgImageLight: lightPageBgImage,
524
522
  wdFontFamily,
@@ -531,8 +529,8 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
531
529
  wdNavbarFgDark: darkNavbarFg,
532
530
  wdNavbarFgMutedDark: darkNavbarFgMuted,
533
531
  wdNavbarAccentDark: darkNavbarAccent,
534
- wdNavbarAccentTextDark: darkNavbarAccentText,
535
532
  wdNavbarBorderDark: darkNavbarBorder,
533
+ wdNavbarHoverDark: darkNavbarHover,
536
534
  wdPageBgColorDark: darkPageBgColor,
537
535
  wdPageBgImageDark: darkPageBgImage,
538
536
  }}
@@ -545,8 +543,8 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
545
543
  --wd-navbar-foreground: var(--wdNavbarFgLight);
546
544
  --wd-navbar-foreground-muted: var(--wdNavbarFgMutedLight);
547
545
  --wd-navbar-accent: var(--wdNavbarAccentLight);
548
- --wd-navbar-accent-text: var(--wdNavbarAccentTextLight);
549
546
  --wd-navbar-border: var(--wdNavbarBorderLight);
547
+ --wd-navbar-hover: var(--wdNavbarHoverLight);
550
548
  --wd-page-bg-color: var(--wdPageBgColorLight);
551
549
  --wd-page-bg-image: var(--wdPageBgImageLight);
552
550
  --wd-text-muted: #64748b;
@@ -572,8 +570,8 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
572
570
  --wd-navbar-foreground: var(--wdNavbarFgDark);
573
571
  --wd-navbar-foreground-muted: var(--wdNavbarFgMutedDark);
574
572
  --wd-navbar-accent: var(--wdNavbarAccentDark);
575
- --wd-navbar-accent-text: var(--wdNavbarAccentTextDark);
576
573
  --wd-navbar-border: var(--wdNavbarBorderDark);
574
+ --wd-navbar-hover: var(--wdNavbarHoverDark);
577
575
  --wd-page-bg-color: var(--wdPageBgColorDark);
578
576
  --wd-page-bg-image: var(--wdPageBgImageDark);
579
577
  --wd-text-muted: #94a3b8;
@@ -37,9 +37,8 @@
37
37
  (below) sits flush against this row's bottom edge (the outer
38
38
  .wd-topbar's own border-bottom, since this is the last row) rather
39
39
  than floating partway up inside a padded gap. That's what lets a
40
- plain border-bottom color change read as a real underline on hover,
41
- and what makes the active tab's own fill look like it's actually
42
- attached to the row's bottom edge instead of a floating pill. */
40
+ plain border-bottom color change read as a real underline under the
41
+ active tab. */
43
42
  padding-bottom: 0;
44
43
  }
45
44
  .wd-topbar-brand {
@@ -139,47 +138,31 @@
139
138
  /* Rounded top corners, square bottom - reads as an actual "tab"
140
139
  attached to the row's bottom edge (see .wd-topbar-row-tabs
141
140
  .wd-topbar-inner's own comment) rather than a floating pill. Same
142
- radius for every tab, active or not, so nothing needs to change
143
- shape on top when a tab becomes active - only the fill/border-bottom
144
- below do. */
141
+ radius for every tab, active or not - it also shapes the hover
142
+ background (below). */
145
143
  border-radius: 0.5rem 0.5rem 0 0;
146
- /* Transparent by default - this is what a hover (below) or the active
147
- state colors in, rather than a separate underline element. Sized to
148
- land exactly on the row's own bottom edge now that this row's inner
149
- wrapper has no bottom padding of its own. */
144
+ /* Transparent by default - the active state (below) colors it in,
145
+ rather than a separate underline element. Sized to land exactly on the
146
+ row's own bottom edge now that this row's inner wrapper has no bottom
147
+ padding of its own. */
150
148
  border-bottom: 2px solid transparent;
151
149
  text-decoration: none;
152
150
  color: var(--wd-navbar-foreground);
153
151
  font-size: 0.92rem;
154
152
  font-weight: 600;
155
153
  }
156
- /* The selected tab: a solid fill, not just a tinted background -
157
- AppIcon's <svg> render mode already uses fill="currentColor" (astro-
158
- icon's default), so its icon recolors along with the text; the
159
- emoji/text <span> fallback inherits `color` the same way.
160
- border-bottom-color matches the fill (rather than being reset to
161
- transparent) so there's no 2px seam of the row's own background
162
- showing between the fill and the row's bottom edge/border.
163
- --wd-navbar-accent (falls back to --wd-primary - see BaseLayout.astro),
164
- not a bare --wd-primary reference: without this, a site whose navbar
165
- background *is* --wd-primary (a plausible "brand-colored navbar" choice
166
- - the exact case that motivated this variable) would have its active
167
- tab's own fill disappear into the row's background entirely. Text color
168
- is --wd-navbar-accent-text (also BaseLayout.astro) rather than a
169
- hardcoded #fff for the matching reason - a light accent color needs
170
- dark text, not white, to stay legible on top of it. */
154
+ /* The selected tab: just its border-bottom, in --wd-navbar-accent (falls
155
+ back to --wd-primary - see BaseLayout.astro), landing on the row's
156
+ bottom edge as an underline. No fill - the text keeps the navbar's own
157
+ color. */
171
158
  .wd-tab-link.active {
172
- color: var(--wd-navbar-accent-text);
173
- background: var(--wd-navbar-accent);
174
159
  border-bottom-color: var(--wd-navbar-accent);
175
160
  }
176
- /* Inactive tabs just get their border-bottom colored in on hover -
177
- simpler than a separate underline element, and lines up naturally
178
- with the active tab's own border-bottom above since both are the same
179
- property. Text/icon color deliberately stays as-is on hover (only the
180
- border appears), matching the reference this was built against. */
181
- .wd-tab-link:hover:not(.active) {
182
- border-bottom-color: var(--wd-navbar-accent);
161
+ /* Any tab on hover: a light background tint, the same as a sidebar item's
162
+ hover (see --wd-navbar-hover in BaseLayout.astro). The rounded top
163
+ corners above shape it. */
164
+ .wd-tab-link:hover {
165
+ background: var(--wd-navbar-hover);
183
166
  }
184
167
  .wd-topbar-links {
185
168
  display: flex;
@@ -158,7 +158,7 @@ const stylesSchema = z.object({
158
158
  // Each side (`light`/`dark`) is either a bare string - just the
159
159
  // background color, exactly the original shape, kept for backwards
160
160
  // compatibility - or `{ background, accent? }`, for when a site also
161
- // wants the navbar's own active-tab-fill/hover-underline color to
161
+ // wants the navbar's own active-tab underline color to
162
162
  // differ from the sitewide `styles.colors.primary` (`accent`'s only
163
163
  // job - see BaseLayout.astro's lightNavbarAccent). Deliberately no
164
164
  // text/icon color field here at all: BaseLayout.astro always resolves
@@ -263,7 +263,7 @@ const logoSchema = z.union([
263
263
  // field's comment for what it now covers.
264
264
  // One side of `styles.navbar` (light or dark) - either a bare background
265
265
  // color (the original shape) or `{ background, accent? }` once a site
266
- // wants its own accent color (the active-tab fill / hover underline)
266
+ // wants its own accent color (the active tab's underline)
267
267
  // inside the navbar specifically, independent of the sitewide
268
268
  // `styles.colors.primary`. Deliberately does NOT carry a text/icon color
269
269
  // field at all - see `navbar`'s own comment below (stylesSchema) for why
@@ -400,7 +400,7 @@ const stylesSchema = z
400
400
  // Each side (`light`/`dark`) is either a bare string - just the
401
401
  // background color, exactly the original shape, kept for backwards
402
402
  // compatibility - or `{ background, accent? }`, for when a site also
403
- // wants the navbar's own active-tab-fill/hover-underline color to
403
+ // wants the navbar's own active-tab underline color to
404
404
  // differ from the sitewide `styles.colors.primary` (`accent`'s only
405
405
  // job - see BaseLayout.astro's lightNavbarAccent). Deliberately no
406
406
  // text/icon color field here at all: BaseLayout.astro always resolves
@@ -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) => {