@writedocs/generator 0.7.1 → 0.7.3

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.
@@ -0,0 +1,112 @@
1
+ // `writedocs update` - installs the latest writedocs, the same way the running
2
+ // one was installed: globally with npm (the documented way), as a project
3
+ // dependency (npm, pnpm or yarn, by the project's lockfile), or globally
4
+ // with pnpm or yarn. npx and a source checkout get an explanation instead.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ import { log, step, color, CliExit } from './output.js';
9
+ import { fetchLatestVersion, isNewer, isSourceCheckout, writeCache } from './update-check.js';
10
+
11
+ /** Runs a command, capturing its output. Resolves { code, output }. */
12
+ function run(command, args, cwd) {
13
+ return new Promise((resolve) => {
14
+ // npm/pnpm/yarn are .cmd scripts on Windows, which spawn() only runs
15
+ // through a shell - given as one string there (Node deprecates an
16
+ // argument list with `shell`). The arguments are fixed: flags and the
17
+ // package name, never user input.
18
+ const options = { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true };
19
+ const child = process.platform === 'win32' ? spawn([command, ...args].join(' '), { ...options, shell: true }) : spawn(command, args, options);
20
+ let output = '';
21
+ child.stdout.on('data', (d) => (output += d));
22
+ child.stderr.on('data', (d) => (output += d));
23
+ child.on('error', (err) => resolve({ code: 1, output: err.message }));
24
+ child.on('close', (code) => resolve({ code: code ?? 1, output }));
25
+ });
26
+ }
27
+
28
+ /** How the running writedocs was installed:
29
+ * { kind: 'checkout' | 'npx' | 'unknown' } or
30
+ * { kind: 'global' | 'local', command, args, cwd }. */
31
+ export async function installation(packageRoot, name) {
32
+ if (isSourceCheckout(packageRoot)) return { kind: 'checkout' };
33
+ const posix = packageRoot.split(path.sep).join('/');
34
+ if (/\/_npx\//.test(posix)) return { kind: 'npx' };
35
+ if (/\/pnpm\/global\//.test(posix)) return { kind: 'global', command: 'pnpm', args: ['add', '-g', `${name}@latest`] };
36
+ if (/\/yarn\/global\//.test(posix)) return { kind: 'global', command: 'yarn', args: ['global', 'add', `${name}@latest`] };
37
+ // pnpm's default layout keeps the real package inside its store,
38
+ // <project>/node_modules/.pnpm/<name>@<version>/node_modules/<name>, and
39
+ // Node resolves the symlink to that path - so the project is what comes
40
+ // before .pnpm, not before the last node_modules/<name>.
41
+ const pnpmStore = posix.indexOf('/node_modules/.pnpm/');
42
+ const at = pnpmStore !== -1 ? pnpmStore : posix.lastIndexOf(`/node_modules/${name}`);
43
+ if (at === -1) return { kind: 'unknown' };
44
+ const container = posix.slice(0, at);
45
+ const npmRoot = await run('npm', ['root', '-g']);
46
+ const globalRoot = npmRoot.code === 0 ? npmRoot.output.trim().split(/\r?\n/).pop() : null;
47
+ if (globalRoot && path.resolve(globalRoot) === path.resolve(`${container}/node_modules`)) {
48
+ return { kind: 'global', command: 'npm', args: ['install', '-g', `${name}@latest`] };
49
+ }
50
+ const cwd = path.resolve(container);
51
+ if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return { kind: 'local', command: 'pnpm', args: ['add', `${name}@latest`], cwd };
52
+ if (fs.existsSync(path.join(cwd, 'yarn.lock'))) return { kind: 'local', command: 'yarn', args: ['add', `${name}@latest`], cwd };
53
+ return { kind: 'local', command: 'npm', args: ['install', `${name}@latest`], cwd };
54
+ }
55
+
56
+ export async function runUpdate({ name, version, packageRoot }) {
57
+ const checking = step('Checking for a new version');
58
+ let latest;
59
+ try {
60
+ latest = await fetchLatestVersion(name, { timeoutMs: 15000 });
61
+ } catch (err) {
62
+ checking.fail("Couldn't check for a new version");
63
+ log.detail(color.dim(`${err.message}. Check your connection, and try again.`));
64
+ throw new CliExit(1);
65
+ }
66
+ checking.stop();
67
+ try {
68
+ writeCache({ name, latest, checkedAt: Date.now() });
69
+ } catch {}
70
+ if (!isNewer(latest, version)) {
71
+ log.success(`writedocs is up to date ${color.dim(`(${version})`)}`);
72
+ return;
73
+ }
74
+
75
+ const install = await installation(packageRoot, name);
76
+ if (install.kind === 'checkout') {
77
+ log.info(`writedocs ${latest} is available, but this one runs from a source checkout (${packageRoot}).`);
78
+ log.detail(color.dim('Update it with git - `writedocs update` only updates installed copies.'));
79
+ return;
80
+ }
81
+ if (install.kind === 'npx') {
82
+ log.info(`writedocs ${latest} is available. You're running writedocs through npx - to use the latest, run:`);
83
+ log.detail(color.cyan(`npx ${name}@latest <command>`));
84
+ log.detail(color.dim(`Or install it once, and use \`writedocs\` directly: npm install -g ${name}`));
85
+ return;
86
+ }
87
+ if (install.kind === 'unknown') {
88
+ log.info(`writedocs ${latest} is available. Update it the way you installed it - for example:`);
89
+ log.detail(color.cyan(`npm install -g ${name}@latest`));
90
+ return;
91
+ }
92
+
93
+ const shown = `${install.command} ${install.args.join(' ')}`;
94
+ const updating = step(`Updating writedocs ${version} → ${latest} ${color.dim(`(${shown}${install.cwd ? ` in ${install.cwd}` : ''})`)}`);
95
+ const result = await run(install.command, install.args, install.cwd);
96
+ if (result.code !== 0) {
97
+ updating.fail(`Couldn't update writedocs - \`${shown}\` failed`);
98
+ const tail = result.output.trim().split(/\r?\n/).slice(-12).join('\n');
99
+ if (tail) {
100
+ log.line();
101
+ log.line(color.dim(tail));
102
+ }
103
+ if (/EACCES|permission denied/i.test(result.output)) {
104
+ log.line();
105
+ log.detail(
106
+ `npm needs permission to write to its global folder. Run ${color.cyan(`sudo ${shown}`)}, or set npm up to install without sudo: https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally`
107
+ );
108
+ }
109
+ throw new CliExit(1);
110
+ }
111
+ updating.succeed(`Updated writedocs ${version} → ${color.bold(latest)}`);
112
+ }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { readConfigText } from '../lib/config-file.js';
3
4
 
4
5
  // Astro's own `output: 'static'` redirects (both writedocs.json's `redirects`
5
6
  // feature and the automatic "/" -> first-nav-page redirect in
@@ -31,7 +32,7 @@ import path from 'node:path';
31
32
  // the one `redirects` array back out, nothing that needs zod's defaults
32
33
  // or transforms.
33
34
  export function writeRedirectsFile(distDir, contentDir) {
34
- const writedocsJson = JSON.parse(fs.readFileSync(path.join(contentDir, 'writedocs.json'), 'utf8'));
35
+ const writedocsJson = JSON.parse(readConfigText(contentDir));
35
36
  const redirects = writedocsJson.redirects ?? [];
36
37
  const lines = [];
37
38
 
@@ -658,7 +658,7 @@ const securityJson = JSON.stringify(op?.security ?? []);
658
658
  border-radius: 0.5rem;
659
659
  border: none;
660
660
  background: var(--wd-primary);
661
- color: #fff;
661
+ color: var(--wd-on-primary);
662
662
  font-size: 0.75rem;
663
663
  font-weight: 600;
664
664
  cursor: pointer;
@@ -931,7 +931,7 @@ const securityJson = JSON.stringify(op?.security ?? []);
931
931
  border-radius: 0.4rem;
932
932
  border: none;
933
933
  background: var(--wd-primary);
934
- color: #fff;
934
+ color: var(--wd-on-primary);
935
935
  font-size: 0.85rem;
936
936
  font-weight: 600;
937
937
  cursor: pointer;
@@ -1123,6 +1123,8 @@ const securityJson = JSON.stringify(op?.security ?? []);
1123
1123
  // after the base (protocol required), preserving method/headers/body,
1124
1124
  // and returns CORS headers so the browser fetch() below succeeds even
1125
1125
  // when the target API itself doesn't send Access-Control-Allow-Origin.
1126
+ // Only a fallback: a request goes straight to the API first, and through
1127
+ // here only when the browser blocks that (see the send handler below).
1126
1128
  // Toggle via writedocs.json's `api.proxy` (see data-proxy on the tryit
1127
1129
  // section below); the displayed curl/fetch/python snippets deliberately
1128
1130
  // keep showing the real, non-proxied URL - only this in-browser request
@@ -1322,17 +1324,26 @@ const securityJson = JSON.stringify(op?.security ?? []);
1322
1324
  const responseBodyEl = section.querySelector<HTMLElement>('[data-role="response-body"] code');
1323
1325
  const modalLangPanels = Array.from(section.querySelectorAll<HTMLElement>('[data-modal-lang-panel]'));
1324
1326
 
1327
+ // A reader's credentials last for the tab, not forever: sessionStorage
1328
+ // carries them across the pages of one visit and forgets them when the
1329
+ // tab closes. Older versions kept them in localStorage indefinitely -
1330
+ // a key found there is moved over once and deleted.
1325
1331
  authInputs.forEach((input) => {
1326
- const name = input.dataset.authName ?? '';
1332
+ const storageKey = `wd-api-auth:${input.dataset.authName ?? ''}`;
1327
1333
  try {
1328
- const saved = localStorage.getItem(`wd-api-auth:${name}`);
1334
+ const legacy = localStorage.getItem(storageKey);
1335
+ if (legacy !== null) {
1336
+ if (sessionStorage.getItem(storageKey) === null) sessionStorage.setItem(storageKey, legacy);
1337
+ localStorage.removeItem(storageKey);
1338
+ }
1339
+ const saved = sessionStorage.getItem(storageKey);
1329
1340
  if (saved) input.value = saved;
1330
1341
  } catch {
1331
- // localStorage unavailable - auth just won't persist across reloads
1342
+ // storage unavailable - auth just won't carry across pages
1332
1343
  }
1333
1344
  input.addEventListener('input', () => {
1334
1345
  try {
1335
- localStorage.setItem(`wd-api-auth:${name}`, input.value);
1346
+ sessionStorage.setItem(storageKey, input.value);
1336
1347
  } catch {
1337
1348
  // ignore
1338
1349
  }
@@ -1714,9 +1725,13 @@ const securityJson = JSON.stringify(op?.security ?? []);
1714
1725
  }
1715
1726
 
1716
1727
  const targetUrl = baseUrl + requestPath + (query.toString() ? `?${query.toString()}` : '');
1717
- // Route through writedocs' CORS proxy unless disabled via writedocs.json's
1718
- // `api.proxy: false` - see PROXY_BASE_URL above for the contract.
1719
- const url = proxyEnabled ? `${PROXY_BASE_URL}${targetUrl}` : targetUrl;
1728
+ // A "simple" request (CORS spec: GET/HEAD/POST, no JSON body, no
1729
+ // auth or custom headers) skips the preflight, so the server may
1730
+ // already have run it when the browser blocks the response.
1731
+ // Retrying one that changes something (a POST) through the proxy
1732
+ // could run it twice - so that one case isn't retried.
1733
+ const isSimple = ['GET', 'HEAD', 'POST'].includes(method) && Object.keys(headers).length === 0;
1734
+ const mayHaveRun = isSimple && method === 'POST';
1720
1735
 
1721
1736
  if (sendBtn) {
1722
1737
  sendBtn.disabled = true;
@@ -1727,8 +1742,23 @@ const securityJson = JSON.stringify(op?.security ?? []);
1727
1742
  responseBodyEl.textContent = '';
1728
1743
 
1729
1744
  const startedAt = performance.now();
1745
+ let viaProxy = false;
1730
1746
  try {
1731
- const res = await fetch(url, { method, headers, body });
1747
+ // Straight to the API first, so a reader's key and body only reach
1748
+ // the API itself. fetch() rejects when the browser blocks the
1749
+ // response (CORS) or can't reach the host - only then, and only
1750
+ // when writedocs.json's `api.proxy` allows it, the same request is
1751
+ // retried through writedocs' proxy (PROXY_BASE_URL above), and the
1752
+ // status line says so.
1753
+ let res: Response;
1754
+ try {
1755
+ res = await fetch(targetUrl, { method, headers, body });
1756
+ } catch (directErr) {
1757
+ if (!proxyEnabled || mayHaveRun) throw directErr;
1758
+ viaProxy = true;
1759
+ responseStatusEl.textContent = 'Blocked by the browser (CORS) - retrying through the writedocs proxy...';
1760
+ res = await fetch(`${PROXY_BASE_URL}${targetUrl}`, { method, headers, body });
1761
+ }
1732
1762
  const elapsed = Math.round(performance.now() - startedAt);
1733
1763
  const text = await res.text();
1734
1764
  let pretty = text;
@@ -1737,11 +1767,15 @@ const securityJson = JSON.stringify(op?.security ?? []);
1737
1767
  } catch {
1738
1768
  // not JSON - show raw text as-is
1739
1769
  }
1740
- responseStatusEl.textContent = `${res.status} ${res.statusText} - ${elapsed}ms`;
1770
+ responseStatusEl.textContent = `${res.status} ${res.statusText} - ${elapsed}ms${viaProxy ? ' - sent through the writedocs proxy (the API blocks direct browser requests)' : ''}`;
1741
1771
  responseStatusEl.className = `wd-api-response-status ${res.ok ? 'wd-api-status-ok' : 'wd-api-status-error'}`;
1742
1772
  responseBodyEl.textContent = pretty;
1743
1773
  } catch (err) {
1744
- responseStatusEl.textContent = 'Request failed - likely blocked by CORS, or the base URL is unreachable from your browser.';
1774
+ responseStatusEl.textContent = viaProxy
1775
+ ? 'Request failed through the writedocs proxy too - the base URL may be unreachable from the internet.'
1776
+ : mayHaveRun && proxyEnabled
1777
+ ? 'Blocked by the browser (CORS). Not retried through the proxy: this POST may already have reached the API.'
1778
+ : 'Request failed - likely blocked by CORS, or the base URL is unreachable from your browser.';
1745
1779
  responseStatusEl.className = 'wd-api-response-status wd-api-status-error';
1746
1780
  responseBodyEl.textContent = err instanceof Error ? err.message : String(err);
1747
1781
  } finally {
@@ -27,7 +27,7 @@ import { extraClasses } from './class-names';
27
27
  height: 1.8rem;
28
28
  padding: 0.45rem;
29
29
  box-sizing: border-box;
30
- color: white;
30
+ color: var(--wd-on-primary);
31
31
  z-index: 1;
32
32
  }
33
33
  span.wd-step-icon {
@@ -47,7 +47,7 @@ import { extraClasses } from './class-names';
47
47
  height: 1.8rem;
48
48
  border-radius: 50%;
49
49
  background: var(--wd-primary);
50
- color: white;
50
+ color: var(--wd-on-primary);
51
51
  font-size: 0.85rem;
52
52
  font-weight: 600;
53
53
  display: flex;
@@ -116,6 +116,9 @@ interface Props {
116
116
  // renders nothing, so this component only needs to know whether to
117
117
  // render the *column wrapper divs* at all).
118
118
  mode?: 'default' | 'wide' | 'frame' | 'custom' | 'blank';
119
+ // The page's language, for <html lang> - the `language` of the
120
+ // navigation level it belongs to (see [...slug].astro), or 'en'.
121
+ lang?: string;
119
122
  }
120
123
  const {
121
124
  config,
@@ -126,6 +129,7 @@ const {
126
129
  selectors = [],
127
130
  globalDropdowns = [],
128
131
  mode = 'default',
132
+ lang = 'en',
129
133
  } = Astro.props as Props;
130
134
  const showSidebarCol = mode === 'default' || mode === 'wide';
131
135
  const showTocCol = mode === 'default';
@@ -158,6 +162,13 @@ const lightPrimary = config.styles.colors.primary;
158
162
  const lightText = config.styles.colors.text ?? '#0f172a';
159
163
  const darkPrimary = config.styles.colors.dark?.primary ?? lightPrimary;
160
164
  const darkText = config.styles.colors.dark?.text ?? '#e2e8f0';
165
+ // Text drawn on the primary color (step numbers, the info banner, the API
166
+ // playground's buttons) - white, or black on a clearly light primary
167
+ // (contrastTextColor(), lib/config.ts). It used to be white everywhere,
168
+ // unreadable on a light primary - and a light dark-mode primary is exactly
169
+ // what dark-mode links need.
170
+ const lightOnPrimary = contrastTextColor(lightPrimary);
171
+ const darkOnPrimary = contrastTextColor(darkPrimary);
161
172
  // styles.background.colors is now the single source for any background
162
173
  // color - both the flat --wd-background surface (dropdowns/modals/kbd
163
174
  // chips/footer/topbar-fallback/etc., every other `var(--wd-background)`
@@ -309,7 +320,7 @@ const footerLogoDark = footerLogoConfig
309
320
  // Zero-config custom CSS/JS - any `.css`/`.js` file anywhere in the
310
321
  // project (root, `docs/`, `snippets/`, `public/`, any subfolder -
311
322
  // `dist/`/`node_modules/`/etc. excluded, see findRootAssets() in
312
- // lib/config.ts) is auto-loaded on every page, no writedocs.json entry
323
+ // lib/pages.js) is auto-loaded on every page, no writedocs.json entry
313
324
  // needed. Read fresh on every render (not cached at module scope) so an
314
325
  // edit to one of these files shows up on the next `writedocs dev` page
315
326
  // load without a server restart - same freshness `loadDocsConfig(contentDir)`
@@ -400,7 +411,7 @@ const fontWeightCss = [
400
411
  const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
401
412
  ---
402
413
  <!doctype html>
403
- <html lang="en">
414
+ <html lang={lang}>
404
415
  <head>
405
416
  <meta charset="UTF-8" />
406
417
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -509,6 +520,7 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
509
520
  <style
510
521
  define:vars={{
511
522
  wdPrimaryLight: lightPrimary,
523
+ wdOnPrimaryLight: lightOnPrimary,
512
524
  wdBackgroundLight: lightBackground,
513
525
  wdTextLight: lightText,
514
526
  wdNavbarLight: lightNavbarBg,
@@ -523,6 +535,7 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
523
535
  wdFontFamilyHeading,
524
536
  wdFontFamilyBody,
525
537
  wdPrimaryDark: darkPrimary,
538
+ wdOnPrimaryDark: darkOnPrimary,
526
539
  wdBackgroundDark: darkBackground,
527
540
  wdTextDark: darkText,
528
541
  wdNavbarDark: darkNavbarBg,
@@ -537,6 +550,7 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
537
550
  >
538
551
  :root {
539
552
  --wd-primary: var(--wdPrimaryLight);
553
+ --wd-on-primary: var(--wdOnPrimaryLight);
540
554
  --wd-background: var(--wdBackgroundLight);
541
555
  --wd-text: var(--wdTextLight);
542
556
  --wd-navbar-background: var(--wdNavbarLight);
@@ -564,6 +578,7 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
564
578
  (src/scripts/theme-toggle.ts). */
565
579
  :root[data-theme='dark'] {
566
580
  --wd-primary: var(--wdPrimaryDark);
581
+ --wd-on-primary: var(--wdOnPrimaryDark);
567
582
  --wd-background: var(--wdBackgroundDark);
568
583
  --wd-text: var(--wdTextDark);
569
584
  --wd-navbar-background: var(--wdNavbarDark);
@@ -603,7 +618,7 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
603
618
  {config.scripts.head.map((s) =>
604
619
  s.src ? <script is:inline src={s.src} /> : <script is:inline set:html={s.content} />
605
620
  )}
606
- {/* Zero-config custom CSS - see findRootAssets() in lib/config.ts and
621
+ {/* Zero-config custom CSS - see findRootAssets() in lib/pages.js and
607
622
  rootCssContents above. Rendered last in <head>, so a project-owned
608
623
  file - anywhere in the project, not just the root - wins over
609
624
  everything else writedocs generates itself. Inlined as raw <style>
@@ -617,7 +632,7 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
617
632
  <style is:inline set:html={content} />
618
633
  ))}
619
634
  {/* Zero-config custom CSS living in public/ - see findRootAssets()'s
620
- own comment in lib/config.ts for why these render as <link> tags
635
+ own comment in lib/pages.js for why these render as <link> tags
621
636
  pointing at their already-public URL instead of joining
622
637
  rootCssContents above as inlined content. */}
623
638
  {publicCssHrefs.map((href) => (
@@ -712,7 +727,7 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
712
727
  {config.scripts.body.map((s) =>
713
728
  s.src ? <script is:inline src={s.src} /> : <script is:inline set:html={s.content} />
714
729
  )}
715
- {/* Zero-config custom JS - see findRootAssets() in lib/config.ts and
730
+ {/* Zero-config custom JS - see findRootAssets() in lib/pages.js and
716
731
  rootJsContents above. Rendered last, after writedocs.json's own
717
732
  `scripts.body`, same "most locally-owned override runs last"
718
733
  reasoning as the root CSS above. `is:inline` here isn't optional -
@@ -725,7 +740,7 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
725
740
  <script is:inline set:html={content} />
726
741
  ))}
727
742
  {/* Zero-config custom JS living in public/ - see this file's own CSS
728
- equivalent above and findRootAssets()'s comment in lib/config.ts.
743
+ equivalent above and findRootAssets()'s comment in lib/pages.js.
729
744
  `<script src>`, not `is:inline set:html` - the file's already a
730
745
  fetchable URL, no reason to inline its content. Filtered against
731
746
  config.scripts.head's own `src` entries (publicJsHrefs) for the
@@ -15,7 +15,7 @@
15
15
  .wd-banner {
16
16
  width: 100%;
17
17
  }
18
- .wd-banner-info { background: var(--wd-primary); color: #fff; }
18
+ .wd-banner-info { background: var(--wd-primary); color: var(--wd-on-primary); }
19
19
  .wd-banner-warning { background: #b45309; color: #fff; }
20
20
  .wd-banner-critical { background: #b91c1c; color: #fff; }
21
21
  .wd-banner-inner {
@@ -5,8 +5,8 @@
5
5
  //
6
6
  // - contrast (WCAG 2 AA, 4.5:1 for text) of the colors writedocs.json sets:
7
7
  // links (styles.colors.primary) on the light and dark backgrounds, body
8
- // text (styles.colors.text) on them, white text on primary (step
9
- // numbers, the info banner), and the navbar's text on its own color.
8
+ // text (styles.colors.text) on them, text on primary (step numbers, the
9
+ // info banner), and the navbar's text on its own color.
10
10
  // Only colors the project sets are checked - writedocs' own defaults
11
11
  // aren't something the author wrote.
12
12
  // - images without alt text: a Markdown image with empty alt, an
@@ -27,6 +27,7 @@ import matter from 'gray-matter';
27
27
  import { visit } from 'unist-util-visit';
28
28
  import { findAllPages } from './pages.js';
29
29
  import { createJsonLocator } from './config-schema.js';
30
+ import { contrastRatio, readableTextOn } from './color.js';
30
31
 
31
32
  const processors = {};
32
33
  async function parse(text, format) {
@@ -44,38 +45,9 @@ const DEFAULT_BACKGROUND = { light: '#ffffff', dark: '#0b1120' };
44
45
  const DEFAULT_TEXT = { light: '#0f172a', dark: '#e2e8f0' };
45
46
  const MIN_TEXT_CONTRAST = 4.5;
46
47
 
47
- function parseHex(value) {
48
- const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(value ?? '').trim());
49
- if (!m) return null;
50
- const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1];
51
- return [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16));
52
- }
53
-
54
- function luminance(rgb) {
55
- const [r, g, b] = rgb.map((v) => {
56
- const c = v / 255;
57
- return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
58
- });
59
- return 0.2126 * r + 0.7152 * g + 0.0722 * b;
60
- }
61
-
62
- /** WCAG contrast ratio of two hex colors, or null when either isn't hex. */
63
- export function contrastRatio(a, b) {
64
- const x = parseHex(a);
65
- const y = parseHex(b);
66
- if (!x || !y) return null;
67
- const [hi, lo] = [luminance(x), luminance(y)].sort((p, q) => q - p);
68
- return (hi + 0.05) / (lo + 0.05);
69
- }
70
-
71
- /** Black or white - the same rule BaseLayout.astro uses for text on a
72
- * configured navbar color (contrastTextColor() in lib/config.ts). */
73
- function navbarTextFor(hex) {
74
- const rgb = parseHex(hex);
75
- if (!rgb) return '#ffffff';
76
- const [r, g, b] = rgb;
77
- return (299 * r + 587 * g + 114 * b) / 1000 > 150 ? '#000000' : '#ffffff';
78
- }
48
+ // contrastRatio() is re-exported: it was defined here, and tests import it
49
+ // from this module.
50
+ export { contrastRatio };
79
51
 
80
52
  function checkColors(config, locate) {
81
53
  const issues = [];
@@ -127,32 +99,37 @@ function checkColors(config, locate) {
127
99
  }
128
100
  }
129
101
 
130
- // White text on the primary color (step numbers, the info banner).
102
+ // Text drawn on the primary color (step numbers, the info banner, the API
103
+ // playground's buttons) - the color the theme picks (readableTextOn() in
104
+ // lib/color.js): white, unless white falls below 3:1 and black takes over.
105
+ // So white between 3:1 and 4.5:1 - most mid-tone brand colors - is the
106
+ // one case left to report.
131
107
  const primaries = [...new Set([colors.primary, colors.dark?.primary].filter(Boolean))];
132
108
  for (const color of primaries) {
133
- const r = low('#ffffff', color);
109
+ const fg = readableTextOn(color);
110
+ const r = low(fg, color);
134
111
  if (r) {
135
112
  push(
136
113
  color === colors.primary ? at('colors', 'primary') : at('colors', 'dark', 'primary'),
137
- `White text on the primary color ${color} (step numbers, the info banner) has a contrast of ${ratioText(r)} (needs ${MIN_TEXT_CONTRAST}:1).`,
138
- 'Use a darker primary color.'
114
+ `${fg === '#ffffff' ? 'White' : 'Black'} text on the primary color ${color} (step numbers, the info banner) has a contrast of ${ratioText(r)} (needs ${MIN_TEXT_CONTRAST}:1).`,
115
+ 'Use a darker primary color - or a much lighter one, which gets black text.'
139
116
  );
140
117
  }
141
118
  }
142
119
 
143
- // The navbar's text on its own color - black or white, picked by a rough
144
- // brightness rule that can land on the weaker of the two.
120
+ // The navbar's text on its own color - the same rule (contrastTextColor()
121
+ // in lib/config.ts), with the same gap for mid-tones.
145
122
  for (const mode of ['light', 'dark']) {
146
123
  const side = styles.navbar?.[mode];
147
124
  const bg = typeof side === 'string' ? side : side?.background;
148
125
  if (!bg) continue;
149
- const fg = navbarTextFor(bg);
126
+ const fg = readableTextOn(bg);
150
127
  const r = low(fg, bg);
151
128
  if (r) {
152
129
  push(
153
130
  at('navbar', mode, ...(typeof side === 'string' ? [] : ['background'])),
154
131
  `The navbar's ${fg === '#ffffff' ? 'white' : 'black'} text on ${bg} (${mode} mode) has a contrast of ${ratioText(r)} (needs ${MIN_TEXT_CONTRAST}:1).`,
155
- 'Use a darker or a lighter navbar color - mid-tones can\'t reach 4.5:1 with either black or white text.'
132
+ 'Use a darker navbar color - or a much lighter one, which gets black text.'
156
133
  );
157
134
  }
158
135
  }
@@ -0,0 +1,29 @@
1
+ // Where a root-relative asset URL points inside a project - for
2
+ // styles-asset-integration.js's dev-server middleware and build copy. Plain
3
+ // JavaScript with no config.ts import, so it can be tested with plain Node.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ /** Resolves a root-relative `urlPath` (e.g. "/images/hero.svg") against
8
+ * `contentDir` directly - not `<contentDir>/public` - the fallback
9
+ * location for one of the styles/footer/seo asset fields
10
+ * (collectConfiguredAssetPaths()) when it isn't sitting under public/.
11
+ * `urlPath` comes from an incoming request URL in the dev-server case, not
12
+ * just trusted writedocs.json content, so it must never resolve outside
13
+ * the project: no `..`/`.` segments, and the resolved path itself has to
14
+ * stay inside contentDir - on Windows `\` is a separator too, and a raw
15
+ * request for `/x\..\..\secret.png` has no `..` segment yet walked out of
16
+ * the project. Returns an absolute path, or null if nothing real is there. */
17
+ export function resolveOutsidePublic(urlPath, contentDir) {
18
+ const segments = urlPath.replace(/^\/+/, '').split('/');
19
+ if (segments.some((segment) => segment === '..' || segment === '.' || segment === '')) return null;
20
+ const root = path.resolve(contentDir);
21
+ const absolute = path.resolve(root, ...segments);
22
+ const inside = path.relative(root, absolute);
23
+ if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) return null;
24
+ try {
25
+ return fs.statSync(absolute).isFile() ? absolute : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
@@ -0,0 +1,49 @@
1
+ // WCAG 2 color math - shared by the theme (BaseLayout.astro, through
2
+ // contrastTextColor() in lib/config.ts), which picks the text color that
3
+ // goes on a configured color, and by `writedocs a11y` (lib/a11y-check.js),
4
+ // which checks that choice. One implementation, so the check always
5
+ // measures what the site actually renders. Plain JavaScript, loaded by
6
+ // plain Node from an installed package (see lib/icons.js's comment).
7
+
8
+ /** [r, g, b] for `#rgb`/`#rrggbb` (the `#` optional), or null. */
9
+ export function parseHex(value) {
10
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(value ?? '').trim());
11
+ if (!m) return null;
12
+ const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1];
13
+ return [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16));
14
+ }
15
+
16
+ /** WCAG relative luminance of an [r, g, b] triple. */
17
+ export function luminance(rgb) {
18
+ const [r, g, b] = rgb.map((v) => {
19
+ const c = v / 255;
20
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
21
+ });
22
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
23
+ }
24
+
25
+ /** WCAG contrast ratio of two hex colors, or null when either isn't hex. */
26
+ export function contrastRatio(a, b) {
27
+ const x = parseHex(a);
28
+ const y = parseHex(b);
29
+ if (!x || !y) return null;
30
+ const [hi, lo] = [luminance(x), luminance(y)].sort((p, q) => q - p);
31
+ return (hi + 0.05) / (lo + 0.05);
32
+ }
33
+
34
+ /** Below this contrast, white text on a color switches to black. */
35
+ export const WHITE_TEXT_MIN_CONTRAST = 3;
36
+
37
+ /** The text color the theme puts on `hex`: white, as brand colors are
38
+ * designed for, unless white falls below 3:1 - a clearly light color
39
+ * (amber, sky, a light dark-mode primary), which gets black instead, at
40
+ * 7:1 or more. Pure "whichever has more contrast" turned most mid-tone
41
+ * brand colors (blue-500, indigo-500, Mintlify's green) black; with this,
42
+ * white between 3:1 and 4.5:1 is kept and `writedocs a11y` reports it
43
+ * (lib/a11y-check.js). White too when `hex` isn't a hex color (a CSS
44
+ * variable, a named color). */
45
+ export function readableTextOn(hex) {
46
+ const white = contrastRatio('#ffffff', hex);
47
+ if (white === null) return '#ffffff';
48
+ return white < WHITE_TEXT_MIN_CONTRAST ? '#000000' : '#ffffff';
49
+ }
@@ -0,0 +1,25 @@
1
+ // Reading writedocs.json (and the other JSON configs `writedocs convert`
2
+ // reads) as text. Plain JavaScript, not TypeScript, so the CLI can load it
3
+ // with plain Node - see lib/icons.js's comment.
4
+ //
5
+ // Windows editors - Notepad, PowerShell 5.1's `Out-File`/`Set-Content
6
+ // -Encoding utf8` - save UTF-8 with a byte order mark. JSON.parse rejects
7
+ // it ("Unexpected token ''"), and the error then blames commas and
8
+ // brackets the file doesn't have. Every reader strips it here instead.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ /** `text` without a leading UTF-8 byte order mark. */
13
+ export function stripBom(text) {
14
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
15
+ }
16
+
17
+ /** A JSON config file's text, byte order mark removed. */
18
+ export function readJsonText(file) {
19
+ return stripBom(fs.readFileSync(file, 'utf-8'));
20
+ }
21
+
22
+ /** `<contentDir>/writedocs.json`'s text, byte order mark removed. */
23
+ export function readConfigText(contentDir) {
24
+ return readJsonText(path.join(contentDir, 'writedocs.json'));
25
+ }