@writedocs/generator 0.7.2 → 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.
@@ -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
+ }
@@ -379,18 +379,15 @@ const pageFrontmatterSchema = z.object({
379
379
  }
380
380
  );
381
381
  const apiSchema = z.object({
382
- // Whether the Try-it modal's Send button routes its request through
383
- // writedocs' own CORS proxy (https://proxy.writechoice.io/) rather
384
- // than calling the API directly from the browser. Almost no
385
- // real-world API sends back Access-Control-Allow-Origin headers
386
- // permitting an arbitrary docs site's origin, so a direct browser
387
- // fetch() from the Try-it modal fails for most real APIs without
388
- // this - see PROXY_BASE_URL in ApiReferencePanel.astro for the
389
- // actual request-forwarding logic. Defaults to enabled; a site can
390
- // set this to `false` to always call the API directly instead (the
391
- // API is already CORS-permissive, same-origin in some deployments,
392
- // or the site owner doesn't want requests routed through a third
393
- // party at all).
382
+ // Whether a Try-it request the browser blocks is retried through
383
+ // writedocs' own CORS proxy (https://proxy.writechoice.io/). The
384
+ // request always goes to the API directly first, so a reader's key
385
+ // reaches the proxy only when the API itself refuses browser calls.
386
+ // Almost no real-world API sends back Access-Control-Allow-Origin
387
+ // headers permitting an arbitrary docs site's origin, so the direct
388
+ // call fails for most real APIs - see the send handler and
389
+ // PROXY_BASE_URL in ApiReferencePanel.astro. Defaults to enabled; a
390
+ // site can set this to `false` to never involve a third party.
394
391
  proxy: z.boolean().default(true)
395
392
  }).strict().default({ proxy: true });
396
393
  const contextMenuSchema = z.object({
@@ -628,6 +625,7 @@ function createJsonLocator(rawText) {
628
625
  return criarLocalizador(rawText);
629
626
  }
630
627
  function criarLocalizador(rawText) {
628
+ rawText = semBom(rawText);
631
629
  let arvore;
632
630
  try {
633
631
  arvore = parseTree(rawText);
@@ -745,6 +743,7 @@ function humanizar(issue, raiz) {
745
743
  }
746
744
  }
747
745
  function unknownRootKeyIssues(rawText) {
746
+ rawText = semBom(rawText);
748
747
  let raiz;
749
748
  try {
750
749
  raiz = JSON.parse(rawText);
@@ -773,7 +772,11 @@ function unknownRootKeyIssues(rawText) {
773
772
  };
774
773
  });
775
774
  }
775
+ function semBom(texto) {
776
+ return texto.charCodeAt(0) === 65279 ? texto.slice(1) : texto;
777
+ }
776
778
  function validateDocsConfig(rawText) {
779
+ rawText = semBom(rawText);
777
780
  let raw;
778
781
  try {
779
782
  raw = JSON.parse(rawText);
@@ -713,18 +713,15 @@ export const pageFrontmatterSchema = z.object({
713
713
  // about the spec itself.
714
714
  const apiSchema = z
715
715
  .object({
716
- // Whether the Try-it modal's Send button routes its request through
717
- // writedocs' own CORS proxy (https://proxy.writechoice.io/) rather
718
- // than calling the API directly from the browser. Almost no
719
- // real-world API sends back Access-Control-Allow-Origin headers
720
- // permitting an arbitrary docs site's origin, so a direct browser
721
- // fetch() from the Try-it modal fails for most real APIs without
722
- // this - see PROXY_BASE_URL in ApiReferencePanel.astro for the
723
- // actual request-forwarding logic. Defaults to enabled; a site can
724
- // set this to `false` to always call the API directly instead (the
725
- // API is already CORS-permissive, same-origin in some deployments,
726
- // or the site owner doesn't want requests routed through a third
727
- // party at all).
716
+ // Whether a Try-it request the browser blocks is retried through
717
+ // writedocs' own CORS proxy (https://proxy.writechoice.io/). The
718
+ // request always goes to the API directly first, so a reader's key
719
+ // reaches the proxy only when the API itself refuses browser calls.
720
+ // Almost no real-world API sends back Access-Control-Allow-Origin
721
+ // headers permitting an arbitrary docs site's origin, so the direct
722
+ // call fails for most real APIs - see the send handler and
723
+ // PROXY_BASE_URL in ApiReferencePanel.astro. Defaults to enabled; a
724
+ // site can set this to `false` to never involve a third party.
728
725
  proxy: z.boolean().default(true),
729
726
  })
730
727
  .strict()
@@ -1322,6 +1319,7 @@ export function createJsonLocator(rawText: string): (path: (string | number)[])
1322
1319
  * quando o no nao existe no texto (campo obrigatorio ausente e o caso comum):
1323
1320
  * `line` ausente e melhor que `line` inventada. */
1324
1321
  function criarLocalizador(rawText: string): (caminho: (string | number)[]) => { line: number; column: number } | null {
1322
+ rawText = semBom(rawText);
1325
1323
  let arvore: ReturnType<typeof parseTree> | undefined;
1326
1324
  try {
1327
1325
  arvore = parseTree(rawText);
@@ -1485,6 +1483,7 @@ function humanizar(issue: IssuePlano, raiz: unknown): { humanMessage?: string; s
1485
1483
  * porque a plataforma repassa `issues` como `errors` e um aviso ali viraria
1486
1484
  * erro. */
1487
1485
  export function unknownRootKeyIssues(rawText: string): ValidationIssue[] {
1486
+ rawText = semBom(rawText);
1488
1487
  let raiz: unknown;
1489
1488
  try {
1490
1489
  raiz = JSON.parse(rawText);
@@ -1516,7 +1515,16 @@ export function unknownRootKeyIssues(rawText: string): ValidationIssue[] {
1516
1515
  });
1517
1516
  }
1518
1517
 
1518
+ /** Texto sem o BOM do UTF-8 no inicio. Editores do Windows (Bloco de Notas, o
1519
+ * `Out-File` do PowerShell 5.1) gravam um, e o JSON.parse o rejeita com uma
1520
+ * mensagem que culpa virgulas e colchetes. Copia local, e nao import de
1521
+ * lib/config-file.js: este modulo so importa zod e jsonc-parser (ver o topo). */
1522
+ function semBom(texto: string): string {
1523
+ return texto.charCodeAt(0) === 0xfeff ? texto.slice(1) : texto;
1524
+ }
1525
+
1519
1526
  export function validateDocsConfig(rawText: string): ValidationResult {
1527
+ rawText = semBom(rawText);
1520
1528
  let raw: unknown;
1521
1529
  try {
1522
1530
  raw = JSON.parse(rawText);
package/src/lib/config.ts CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { EXCLUDED_TOP_LEVEL_DIRS } from './pages.js';
4
4
  import matter from 'gray-matter';
5
5
  import { writedocsTempDir } from './writedocs-temp-dir.js';
6
+ import { readableTextOn } from './color.js';
6
7
  import {
7
8
  formatValidationIssues,
8
9
  validateDocsConfig,
@@ -208,13 +209,12 @@ export function resolveNavbarColor(
208
209
  return { background: value.background, accent: value.accent };
209
210
  }
210
211
 
211
- /** Picks black or white text for readable contrast against `hexColor`,
212
- * via the standard relative-luminance formula (ITU-R BT.601 weights -
213
- * the same "perceived brightness" approximation used all over the web
214
- * for exactly this "what text color goes on this swatch" problem, not
215
- * the more expensive WCAG relative-luminance formula, which isn't
216
- * needed for a binary choose-the-less-bad-option decision like this
217
- * one). Used two ways in BaseLayout.astro: for `--wd-navbar-accent-text`
212
+ /** Picks black or white text for `hexColor` - white unless white falls
213
+ * below 3:1 on it (readableTextOn() in lib/color.js), the same rule and
214
+ * WCAG math `writedocs a11y` checks with, so the check measures what the
215
+ * site renders. (This used a BT.601 brightness threshold.) Also
216
+ * gives `--wd-on-primary`, the text on step numbers, the info banner and
217
+ * the API playground's buttons. Used two ways in BaseLayout.astro: for `--wd-navbar-accent-text`
218
218
  * (the active tab's own fill used to always be `var(--wd-primary)` with
219
219
  * hardcoded `color: #fff`, which only actually read fine because every
220
220
  * default/example primary color so far has been dark/saturated enough
@@ -224,19 +224,12 @@ export function resolveNavbarColor(
224
224
  * hold unconditionally), and for `--wd-navbar-foreground` itself once
225
225
  * `styles.navbar` is configured at all (the navbar's plain text/icon
226
226
  * color - see `navbar`'s own schema comment for why that's always
227
- * computed, never a writedocs.json value). Malformed input (not a 6-digit
228
- * `#rrggbb` hex) falls back to white rather than throwing - same "don't
227
+ * computed, never a writedocs.json value). Malformed input (not a
228
+ * `#rgb`/`#rrggbb` hex) falls back to white rather than throwing - same "don't
229
229
  * fail a build over a cosmetic color value" posture every other color
230
230
  * field here takes (none of them validate hex syntax either). */
231
231
  export function contrastTextColor(hexColor: string): '#000000' | '#ffffff' {
232
- const match = /^#?([0-9a-f]{6})$/i.exec(hexColor.trim());
233
- if (!match) return '#ffffff';
234
- const hex = match[1];
235
- const r = parseInt(hex.slice(0, 2), 16);
236
- const g = parseInt(hex.slice(2, 4), 16);
237
- const b = parseInt(hex.slice(4, 6), 16);
238
- const luminance = (299 * r + 587 * g + 114 * b) / 1000;
239
- return luminance > 150 ? '#000000' : '#ffffff';
232
+ return readableTextOn(hexColor) as '#000000' | '#ffffff';
240
233
  }
241
234
 
242
235
  /** Whether an href points off-site - has an explicit scheme (`https:`,
@@ -481,122 +474,9 @@ export function loadDocsConfig(contentDir: string): DocsConfig {
481
474
  // JavaScript, so `writedocs validate` can find a site's pages with plain Node.
482
475
  export { findAllPages } from './pages.js';
483
476
 
484
- /** Any `.css`/`.js` file anywhere in the project - root or any subfolder,
485
- * `public/` included - auto-loaded site-wide with zero `writedocs.json`
486
- * config, on top of (not instead of) the explicit `scripts` field
487
- * (bannerSchema and friends, above). Drop a file in, it loads;
488
- * there's no field naming which ones to use, matching the same "just
489
- * works" convention `docs/`'s own file discovery already follows (see
490
- * findAllPages() above / `content-pipeline.mdx`) - a site author already
491
- * drops content files in and expects them found, rather than also
492
- * listing every one in writedocs.json.
493
- *
494
- * Two separate walks, because `public/` needs different treatment than
495
- * everywhere else:
496
- *
497
- * - `css`/`js`: every `.css`/`.js` file outside `public/` (root, `docs/`,
498
- * `snippets/`, any custom folder) - reused as the walk-with-exclusions
499
- * shape from findAllPages() above, and its exact `EXCLUDED_TOP_LEVEL_DIRS`
500
- * set (skipped only at the project root, same as there), so `dist/`,
501
- * `node_modules/`, `.astro/`, `.writedocs/`, `.git/`, and (for this
502
- * half only - see below) `public/` are never walked into. BaseLayout.astro
503
- * reads each one's raw content and inlines it as a `<style>`/
504
- * `<script is:inline>` tag.
505
- * - `publicCss`/`publicJs`: every `.css`/`.js` file *inside* `public/`,
506
- * walked separately (starting from `<contentDir>/public` rather than
507
- * `contentDir` itself, so the same `EXCLUDED_TOP_LEVEL_DIRS` check
508
- * doesn't apply here - there's no `public/public/` or `public/dist/`
509
- * convention to guard against). Returned as public-URL-rooted hrefs
510
- * (a leading `/`, no `public` segment - `public/custom.css` becomes
511
- * `/custom.css`) rather than content-dir-relative paths, since these
512
- * files are already served as static assets at exactly that URL once
513
- * Astro copies `public/` into the build output. BaseLayout.astro
514
- * renders these as ordinary `<link rel="stylesheet">`/`<script src>`
515
- * tags pointing at that URL instead of inlining their content -
516
- * inlining would duplicate every byte (once in the page's own HTML,
517
- * once more as the independently-fetchable static file at that same
518
- * URL) for no benefit, where a `<link>`/`<script src>` gets normal
519
- * browser caching across pages instead of repeating the content on
520
- * every single page's markup.
521
- *
522
- * Both halves are broader than they might sound - a stray `.js` file
523
- * kept in `docs/`, `snippets/`, or `public/` for an unrelated reason
524
- * (a snippet's own local helper, an image gallery's lightbox script
525
- * someone dropped in `public/` to reference from a raw `<script src>`
526
- * in an .mdx file, say) gets auto-injected sitewide the same as a
527
- * deliberate one; there's no separate "this one's just tooling" signal
528
- * to opt out of the convention short of renaming its extension.
529
- *
530
- * Both `css`/`js` and `publicCss`/`publicJs` are sorted alphabetically
531
- * by their respective path for deterministic load order across rebuilds
532
- * - same reasoning as llms.txt's own alphabetical-by-slug sort (see
533
- * llms.txt.ts) - filesystem readdir order isn't guaranteed portable
534
- * across OSes or directory-walk order otherwise.
535
- *
536
- * `css`/`js` return POSIX-separated paths relative to `contentDir`, not
537
- * absolute paths or file contents - BaseLayout.astro (the sole caller)
538
- * resolves and reads each one's content itself, right before inlining
539
- * it, so a file's content is always current as of that specific
540
- * request/build rather than cached here across a `writedocs dev`
541
- * session. `publicCss`/`publicJs` return the public-URL hrefs described
542
- * above - nothing to read, Astro's own static-file serving/copy already
543
- * handles those. */
544
- export function findRootAssets(
545
- contentDir: string
546
- ): { css: string[]; js: string[]; publicCss: string[]; publicJs: string[] } {
547
- const css: string[] = [];
548
- const js: string[] = [];
549
- function walk(dir: string, relBase: string) {
550
- let entries: fs.Dirent[];
551
- try {
552
- entries = fs.readdirSync(dir, { withFileTypes: true });
553
- } catch {
554
- return;
555
- }
556
- for (const entry of entries) {
557
- const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
558
- const abs = path.join(dir, entry.name);
559
- if (entry.isDirectory()) {
560
- if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
561
- walk(abs, rel);
562
- continue;
563
- }
564
- if (!entry.isFile()) continue;
565
- if (/\.css$/i.test(entry.name)) css.push(rel);
566
- else if (/\.js$/i.test(entry.name)) js.push(rel);
567
- }
568
- }
569
- walk(contentDir, '');
570
- css.sort();
571
- js.sort();
572
-
573
- const publicCss: string[] = [];
574
- const publicJs: string[] = [];
575
- function walkPublic(dir: string, relBase: string) {
576
- let entries: fs.Dirent[];
577
- try {
578
- entries = fs.readdirSync(dir, { withFileTypes: true });
579
- } catch {
580
- return;
581
- }
582
- for (const entry of entries) {
583
- const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
584
- const abs = path.join(dir, entry.name);
585
- if (entry.isDirectory()) {
586
- walkPublic(abs, rel);
587
- continue;
588
- }
589
- if (!entry.isFile()) continue;
590
- if (/\.css$/i.test(entry.name)) publicCss.push(`/${rel}`);
591
- else if (/\.js$/i.test(entry.name)) publicJs.push(`/${rel}`);
592
- }
593
- }
594
- walkPublic(path.join(contentDir, 'public'), '');
595
- publicCss.sort();
596
- publicJs.sort();
597
-
598
- return { css, js, publicCss, publicJs };
599
- }
477
+ // findRootAssets() lives in lib/pages.js - plain JavaScript, so it can be
478
+ // tested with plain Node, next to findAllPages() whose exclusions it shares.
479
+ export { findRootAssets } from './pages.js';
600
480
 
601
481
  /** The root-relative URL paths (leading `/`, e.g. `/images/hero.svg`)
602
482
  * referenced by the writedocs.json/styles fields that point at a static asset
@@ -615,7 +495,7 @@ export function findRootAssets(
615
495
  * `public/` was the one place `styles.background.images` (etc.) had to
616
496
  * live, since Astro's own `publicDir` copy is the only thing that ever
617
497
  * served them; everywhere else in this codebase's own "drop a file
618
- * anywhere, it's found" convention (`findRootAssets()` right above,
498
+ * anywhere, it's found" convention (`findRootAssets()` in lib/pages.js,
619
499
  * `findAllPages()` for content) already worked project-wide. See that
620
500
  * integration's own comment for the actual resolution mechanism (a dev-time
621
501
  * middleware plus a post-build copy step, not a duplicated `publicDir`)
@@ -733,7 +613,10 @@ export function fileIdForEntry(
733
613
  const absoluteContentDir = path.resolve(contentDir);
734
614
  const absoluteFilePath = path.resolve(packageRoot, entry.filePath);
735
615
  const relativeToContentDir = path.relative(absoluteContentDir, absoluteFilePath);
736
- if (relativeToContentDir.startsWith('..')) return entry.id;
616
+ // Outside contentDir: `..`-prefixed, or - on Windows, when the file is on
617
+ // another drive (temp on C:, project on D:) - an absolute path, since
618
+ // path.relative() can't bridge drives.
619
+ if (relativeToContentDir.startsWith('..') || path.isAbsolute(relativeToContentDir)) return entry.id;
737
620
  const posixRelative = relativeToContentDir.split(path.sep).join('/');
738
621
  if (EXCLUDED_TOP_LEVEL_DIRS.has(posixRelative.split('/')[0])) return entry.id;
739
622
  return posixRelative.replace(/\.mdx?$/i, '').replace(/\/index$/, '');