@writedocs/generator 0.7.2 → 0.7.4
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/astro.config.mjs +10 -4
- package/bin/writedocs.js +28 -7
- package/package.json +1 -1
- package/src/cli/build-auth.js +16 -9
- package/src/cli/build.js +3 -1
- package/src/cli/convert.js +6 -2
- package/src/cli/dev.js +3 -1
- package/src/cli/generate-api-pages.js +2 -2
- package/src/cli/init.js +3 -1
- package/src/cli/output.js +8 -1
- package/src/cli/preflight.js +63 -1
- package/src/cli/update.js +6 -1
- package/src/cli/write-redirects-file.js +2 -1
- package/src/components/ApiReferencePanel.astro +46 -12
- package/src/components/Steps.astro +2 -2
- package/src/layout/BaseLayout.astro +21 -6
- package/src/layout/styles/banner.css +1 -1
- package/src/lib/a11y-check.js +19 -42
- package/src/lib/agent-markdown.js +137 -0
- package/src/lib/asset-path.js +29 -0
- package/src/lib/color.js +49 -0
- package/src/lib/config-file.js +25 -0
- package/src/lib/config-schema.js +15 -12
- package/src/lib/config-schema.ts +20 -12
- package/src/lib/config.ts +18 -135
- package/src/lib/json-schema-descriptions.js +1 -1
- package/src/lib/llms-index.ts +88 -0
- package/src/lib/llms.js +200 -0
- package/src/lib/mintlify-convert.js +2 -1
- package/src/lib/pages.js +137 -11
- package/src/lib/styles-asset-integration.js +1 -18
- package/src/lib/writedocs-legacy-convert.js +2 -1
- package/src/pages/[...slug].astro +11 -1
- package/src/pages/[...slug].md.ts +15 -6
- package/src/pages/llms/[...path].md.ts +23 -0
- package/src/pages/llms-full.txt.ts +30 -9
- package/src/pages/llms.txt.ts +21 -125
- package/src/scripts/search.ts +39 -14
- package/writedocs.schema.json +2 -2
|
@@ -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/
|
|
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=
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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:
|
|
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 {
|
package/src/lib/a11y-check.js
CHANGED
|
@@ -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,
|
|
9
|
-
//
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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 -
|
|
144
|
-
//
|
|
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 =
|
|
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
|
|
132
|
+
'Use a darker navbar color - or a much lighter one, which gets black text.'
|
|
156
133
|
);
|
|
157
134
|
}
|
|
158
135
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The Markdown an AI agent reads for a page - llms-full.txt and the per-page
|
|
2
|
+
// .md route. It starts from the page's raw MDX, so everything the build does
|
|
3
|
+
// to that source on the way to HTML has to be redone here, or the agent reads
|
|
4
|
+
// something other than the page: `<Visibility>` blocks, imported .mdx
|
|
5
|
+
// snippets (inlined, as the page shows them) and writedocs.json `variables`.
|
|
6
|
+
//
|
|
7
|
+
// Code is left exactly as written - fenced blocks and inline `code` - the same
|
|
8
|
+
// way the build never substitutes inside code: a page documenting the
|
|
9
|
+
// `[[key]]` syntax, or showing `<Snippet />` in an example, must keep it.
|
|
10
|
+
// Plain JavaScript, so it can be tested with plain Node.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import matter from 'gray-matter';
|
|
14
|
+
import { applyVisibilityForAgents } from './visibility.js';
|
|
15
|
+
|
|
16
|
+
/** `text` with `transform` applied to everything outside fenced code blocks
|
|
17
|
+
* and inline code spans; code comes back untouched. */
|
|
18
|
+
export function mapOutsideCode(text, transform) {
|
|
19
|
+
const out = [];
|
|
20
|
+
let prose = [];
|
|
21
|
+
let fence = null; // the opening marker (``` or ~~~, maybe longer) while inside a block
|
|
22
|
+
const flush = () => {
|
|
23
|
+
if (prose.length) out.push(mapOutsideInlineCode(prose.join('\n'), transform));
|
|
24
|
+
prose = [];
|
|
25
|
+
};
|
|
26
|
+
for (const line of text.split('\n')) {
|
|
27
|
+
const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
|
|
28
|
+
if (fence) {
|
|
29
|
+
out.push(line);
|
|
30
|
+
if (marker && marker[0] === fence[0] && marker.length >= fence.length && line.trim() === marker) fence = null;
|
|
31
|
+
} else if (marker) {
|
|
32
|
+
flush();
|
|
33
|
+
fence = marker;
|
|
34
|
+
out.push(line);
|
|
35
|
+
} else {
|
|
36
|
+
prose.push(line);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
flush();
|
|
40
|
+
return out.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mapOutsideInlineCode(text, transform) {
|
|
44
|
+
// Backtick runs of equal length delimit a code span (CommonMark).
|
|
45
|
+
return text
|
|
46
|
+
.split(/(`+)([\s\S]*?)(\1)(?!`)/)
|
|
47
|
+
.reduce((acc, part, i, parts) => {
|
|
48
|
+
const k = i % 4;
|
|
49
|
+
if (k === 0) acc.push(transform(part));
|
|
50
|
+
else if (k === 1) acc.push(part + parts[i + 1] + parts[i + 2]);
|
|
51
|
+
return acc;
|
|
52
|
+
}, [])
|
|
53
|
+
.join('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PLACEHOLDER = /\[\[\s*([\w.-]+)\s*\]\]/g;
|
|
57
|
+
// A standalone `{key}` expression - not an attribute value (`prop={key}`),
|
|
58
|
+
// which the build leaves alone too.
|
|
59
|
+
const MINTLIFY_PLACEHOLDER = /(?<!=\s*)\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
|
|
60
|
+
|
|
61
|
+
/** writedocs.json `variables` - `[[key]]`, and Mintlify's `{key}` - replaced
|
|
62
|
+
* the way the build replaces them (lib/mdx-substitute-variables.js): outside
|
|
63
|
+
* code, and only for keys that exist. */
|
|
64
|
+
export function substituteVariables(text, variables) {
|
|
65
|
+
if (!variables || Object.keys(variables).length === 0) return text;
|
|
66
|
+
const has = (key) => Object.prototype.hasOwnProperty.call(variables, key);
|
|
67
|
+
return mapOutsideCode(text, (prose) =>
|
|
68
|
+
prose
|
|
69
|
+
.replace(PLACEHOLDER, (match, key) => (has(key) ? String(variables[key]) : match))
|
|
70
|
+
.replace(MINTLIFY_PLACEHOLDER, (match, key) => (has(key) ? String(variables[key]) : match))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const IMPORT_LINE = /^[ \t]*import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+\.mdx?)['"];?[ \t]*$/gm;
|
|
75
|
+
|
|
76
|
+
function resolveSnippet(spec, fromFile, contentDir) {
|
|
77
|
+
if (spec.startsWith('/snippets/')) return path.join(contentDir, 'snippets', spec.slice('/snippets/'.length));
|
|
78
|
+
if (spec.startsWith('./') || spec.startsWith('../')) return path.resolve(path.dirname(fromFile), spec);
|
|
79
|
+
if (spec.startsWith('/')) return path.join(contentDir, spec.slice(1));
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function attributesOf(source) {
|
|
84
|
+
const props = {};
|
|
85
|
+
for (const m of source.matchAll(/([A-Za-z_$][\w$-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*["']([^"']*)["']\s*\})/g)) {
|
|
86
|
+
props[m[1]] = m[2] ?? m[3] ?? m[4];
|
|
87
|
+
}
|
|
88
|
+
return props;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fillProps(snippet, props, children) {
|
|
92
|
+
return snippet
|
|
93
|
+
.replace(/\{\s*props\.children\s*\}/g, children ?? '')
|
|
94
|
+
.replace(/\{\s*props\.([A-Za-z_$][\w$]*)\s*\}/g, (match, key) => (key in props ? props[key] : match));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Imported .mdx snippets inlined where the page uses them - the snippet's
|
|
98
|
+
* own text, with `{props.x}` filled from the tag's string attributes, and
|
|
99
|
+
* its own imports inlined in turn. The import line goes too. A snippet that
|
|
100
|
+
* can't be read, or a .jsx/.tsx component, stays as written. */
|
|
101
|
+
export function inlineSnippets(text, { file, contentDir, depth = 0 }) {
|
|
102
|
+
if (depth > 5) return text;
|
|
103
|
+
const snippets = new Map();
|
|
104
|
+
let result = mapOutsideCode(text, (prose) =>
|
|
105
|
+
prose.replace(IMPORT_LINE, (line, name, spec) => {
|
|
106
|
+
const target = resolveSnippet(spec, file, contentDir);
|
|
107
|
+
let raw;
|
|
108
|
+
try {
|
|
109
|
+
raw = target && fs.readFileSync(target, 'utf-8');
|
|
110
|
+
} catch {
|
|
111
|
+
raw = null;
|
|
112
|
+
}
|
|
113
|
+
if (!raw) return line;
|
|
114
|
+
const body = inlineSnippets(matter(raw).content.trim(), { file: target, contentDir, depth: depth + 1 });
|
|
115
|
+
snippets.set(name, body);
|
|
116
|
+
return '';
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
for (const [name, body] of snippets) {
|
|
120
|
+
result = mapOutsideCode(result, (prose) =>
|
|
121
|
+
prose
|
|
122
|
+
.replace(new RegExp(`<${name}\\b([^>]*?)\\/>`, 'g'), (_, attrs) => fillProps(body, attributesOf(attrs)))
|
|
123
|
+
.replace(new RegExp(`<${name}\\b([^>]*)>([\\s\\S]*?)<\\/${name}>`, 'g'), (_, attrs, children) =>
|
|
124
|
+
fillProps(body, attributesOf(attrs), children.trim())
|
|
125
|
+
)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return result.replace(/\n{3,}/g, '\n\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A page's body as an agent should read it. `file` is the page's absolute
|
|
132
|
+
* path (to resolve relative snippet imports). */
|
|
133
|
+
export function markdownForAgents(body, { file, contentDir, variables }) {
|
|
134
|
+
let text = applyVisibilityForAgents(body ?? '');
|
|
135
|
+
if (file) text = inlineSnippets(text, { file, contentDir });
|
|
136
|
+
return substituteVariables(text, variables);
|
|
137
|
+
}
|
|
@@ -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
|
+
}
|
package/src/lib/color.js
ADDED
|
@@ -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 U+FEFF"), 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
|
+
}
|
package/src/lib/config-schema.js
CHANGED
|
@@ -379,18 +379,15 @@ const pageFrontmatterSchema = z.object({
|
|
|
379
379
|
}
|
|
380
380
|
);
|
|
381
381
|
const apiSchema = z.object({
|
|
382
|
-
// Whether
|
|
383
|
-
// writedocs' own CORS proxy (https://proxy.writechoice.io/)
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
// set this to `false` to
|
|
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);
|
package/src/lib/config-schema.ts
CHANGED
|
@@ -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
|
|
717
|
-
// writedocs' own CORS proxy (https://proxy.writechoice.io/)
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
// set this to `false` to
|
|
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);
|