@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.
- package/astro.config.mjs +10 -4
- package/bin/writedocs.js +54 -16
- 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 +7 -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-check-refresh.js +18 -0
- package/src/cli/update-check.js +104 -0
- package/src/cli/update.js +112 -0
- 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/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/mintlify-convert.js +2 -1
- package/src/lib/pages.js +121 -1
- 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/llms.txt.ts +5 -1
- package/src/scripts/search.ts +39 -14
- package/writedocs.schema.json +2 -2
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);
|
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
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* the
|
|
216
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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()`
|
|
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
|
-
|
|
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$/, '');
|
|
@@ -155,7 +155,7 @@ export const DESCRIPTIONS = {
|
|
|
155
155
|
...logo('footer.logo'),
|
|
156
156
|
|
|
157
157
|
api: 'The API playground.',
|
|
158
|
-
'api.proxy': '
|
|
158
|
+
'api.proxy': 'When the browser blocks a "Try it" request (the API doesn\'t allow calls from other sites - CORS), retry it through writedocs\' proxy. Requests always go to the API directly first. Default true.',
|
|
159
159
|
domain: 'The site\'s address, like "docs.example.com". Turns on sitemap.xml and absolute URLs in link previews.',
|
|
160
160
|
|
|
161
161
|
seo: 'Default metadata for every page. A page\'s own `seo` frontmatter overrides it field by field.',
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { iconExists } from './icons.js';
|
|
14
|
+
import { readJsonText } from './config-file.js';
|
|
14
15
|
|
|
15
16
|
// ---------------------------------------------------------------------
|
|
16
17
|
// Reading docs.json
|
|
@@ -28,7 +29,7 @@ export function loadMintlifyConfig(file) {
|
|
|
28
29
|
if (seen.has(abs)) throw new Error(`Circular $ref: ${path.relative(root, abs)}`);
|
|
29
30
|
seen.add(abs);
|
|
30
31
|
try {
|
|
31
|
-
return resolve(JSON.parse(
|
|
32
|
+
return resolve(JSON.parse(readJsonText(abs)), path.dirname(abs));
|
|
32
33
|
} finally {
|
|
33
34
|
seen.delete(abs);
|
|
34
35
|
}
|
package/src/lib/pages.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import matter from 'gray-matter';
|
|
8
|
+
import { readConfigText } from './config-file.js';
|
|
8
9
|
|
|
9
10
|
// Top-level folders of a content directory that are never scanned for
|
|
10
11
|
// pages - build output, dependencies, and static assets. Any folder whose
|
|
@@ -63,7 +64,7 @@ function readIgnoreFile(contentDir) {
|
|
|
63
64
|
function navigationPageIds(contentDir) {
|
|
64
65
|
let config;
|
|
65
66
|
try {
|
|
66
|
-
config = JSON.parse(
|
|
67
|
+
config = JSON.parse(readConfigText(contentDir));
|
|
67
68
|
} catch {
|
|
68
69
|
return new Set();
|
|
69
70
|
}
|
|
@@ -148,3 +149,122 @@ export function findAllPages(contentDir) {
|
|
|
148
149
|
export function fileIdForPath(relativePath) {
|
|
149
150
|
return relativePath.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
|
|
150
151
|
}
|
|
152
|
+
|
|
153
|
+
/** Any `.css`/`.js` file anywhere in the project - root or any subfolder,
|
|
154
|
+
* `public/` included - auto-loaded site-wide with zero `writedocs.json`
|
|
155
|
+
* config, on top of (not instead of) the explicit `scripts` field
|
|
156
|
+
* (bannerSchema and friends, above). Drop a file in, it loads;
|
|
157
|
+
* there's no field naming which ones to use, matching the same "just
|
|
158
|
+
* works" convention `docs/`'s own file discovery already follows (see
|
|
159
|
+
* findAllPages() above / `content-pipeline.mdx`) - a site author already
|
|
160
|
+
* drops content files in and expects them found, rather than also
|
|
161
|
+
* listing every one in writedocs.json.
|
|
162
|
+
*
|
|
163
|
+
* Two separate walks, because `public/` needs different treatment than
|
|
164
|
+
* everywhere else:
|
|
165
|
+
*
|
|
166
|
+
* - `css`/`js`: every `.css`/`.js` file outside `public/` (root, `docs/`,
|
|
167
|
+
* `snippets/`, any custom folder) - reused as the walk-with-exclusions
|
|
168
|
+
* shape from findAllPages() above, and its exact `EXCLUDED_TOP_LEVEL_DIRS`
|
|
169
|
+
* set (skipped only at the project root, same as there), so `dist/`,
|
|
170
|
+
* `node_modules/`, `.astro/`, `.writedocs/`, `.git/`, and (for this
|
|
171
|
+
* half only - see below) `public/` are never walked into. BaseLayout.astro
|
|
172
|
+
* reads each one's raw content and inlines it as a `<style>`/
|
|
173
|
+
* `<script is:inline>` tag.
|
|
174
|
+
* - `publicCss`/`publicJs`: every `.css`/`.js` file *inside* `public/`,
|
|
175
|
+
* walked separately (starting from `<contentDir>/public` rather than
|
|
176
|
+
* `contentDir` itself, so the same `EXCLUDED_TOP_LEVEL_DIRS` check
|
|
177
|
+
* doesn't apply here - there's no `public/public/` or `public/dist/`
|
|
178
|
+
* convention to guard against). Returned as public-URL-rooted hrefs
|
|
179
|
+
* (a leading `/`, no `public` segment - `public/custom.css` becomes
|
|
180
|
+
* `/custom.css`) rather than content-dir-relative paths, since these
|
|
181
|
+
* files are already served as static assets at exactly that URL once
|
|
182
|
+
* Astro copies `public/` into the build output. BaseLayout.astro
|
|
183
|
+
* renders these as ordinary `<link rel="stylesheet">`/`<script src>`
|
|
184
|
+
* tags pointing at that URL instead of inlining their content -
|
|
185
|
+
* inlining would duplicate every byte (once in the page's own HTML,
|
|
186
|
+
* once more as the independently-fetchable static file at that same
|
|
187
|
+
* URL) for no benefit, where a `<link>`/`<script src>` gets normal
|
|
188
|
+
* browser caching across pages instead of repeating the content on
|
|
189
|
+
* every single page's markup.
|
|
190
|
+
*
|
|
191
|
+
* Both halves are broader than they might sound - a stray `.js` file
|
|
192
|
+
* kept in `docs/`, `snippets/`, or `public/` for an unrelated reason
|
|
193
|
+
* (a snippet's own local helper, an image gallery's lightbox script
|
|
194
|
+
* someone dropped in `public/` to reference from a raw `<script src>`
|
|
195
|
+
* in an .mdx file, say) gets auto-injected sitewide the same as a
|
|
196
|
+
* deliberate one; there's no separate "this one's just tooling" signal
|
|
197
|
+
* to opt out of the convention short of renaming its extension.
|
|
198
|
+
*
|
|
199
|
+
* Both `css`/`js` and `publicCss`/`publicJs` are sorted alphabetically
|
|
200
|
+
* by their respective path for deterministic load order across rebuilds
|
|
201
|
+
* - same reasoning as llms.txt's own alphabetical-by-slug sort (see
|
|
202
|
+
* llms.txt.ts) - filesystem readdir order isn't guaranteed portable
|
|
203
|
+
* across OSes or directory-walk order otherwise.
|
|
204
|
+
*
|
|
205
|
+
* `css`/`js` return POSIX-separated paths relative to `contentDir`, not
|
|
206
|
+
* absolute paths or file contents - BaseLayout.astro (the sole caller)
|
|
207
|
+
* resolves and reads each one's content itself, right before inlining
|
|
208
|
+
* it, so a file's content is always current as of that specific
|
|
209
|
+
* request/build rather than cached here across a `writedocs dev`
|
|
210
|
+
* session. `publicCss`/`publicJs` return the public-URL hrefs described
|
|
211
|
+
* above - nothing to read, Astro's own static-file serving/copy already
|
|
212
|
+
* handles those. */
|
|
213
|
+
export function findRootAssets(contentDir) {
|
|
214
|
+
const css = [];
|
|
215
|
+
const js = [];
|
|
216
|
+
function walk(dir, relBase) {
|
|
217
|
+
let entries;
|
|
218
|
+
try {
|
|
219
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
220
|
+
} catch {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
225
|
+
const abs = path.join(dir, entry.name);
|
|
226
|
+
if (entry.isDirectory()) {
|
|
227
|
+
if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
|
|
228
|
+
// Dot-folders (.github, .claude, .husky, .vscode, ...) hold tooling,
|
|
229
|
+
// never site assets - the same rule findAllPages() applies to pages.
|
|
230
|
+
// Without it, a CI script under .github/ ran on every page.
|
|
231
|
+
if (entry.name.startsWith('.')) continue;
|
|
232
|
+
walk(abs, rel);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!entry.isFile() || entry.name.startsWith('.')) continue; // .eslintrc.js and friends
|
|
236
|
+
if (/\.css$/i.test(entry.name)) css.push(rel);
|
|
237
|
+
else if (/\.js$/i.test(entry.name)) js.push(rel);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
walk(contentDir, '');
|
|
241
|
+
css.sort();
|
|
242
|
+
js.sort();
|
|
243
|
+
|
|
244
|
+
const publicCss = [];
|
|
245
|
+
const publicJs = [];
|
|
246
|
+
function walkPublic(dir, relBase) {
|
|
247
|
+
let entries;
|
|
248
|
+
try {
|
|
249
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
250
|
+
} catch {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
255
|
+
const abs = path.join(dir, entry.name);
|
|
256
|
+
if (entry.isDirectory()) {
|
|
257
|
+
walkPublic(abs, rel);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!entry.isFile()) continue;
|
|
261
|
+
if (/\.css$/i.test(entry.name)) publicCss.push(`/${rel}`);
|
|
262
|
+
else if (/\.js$/i.test(entry.name)) publicJs.push(`/${rel}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
walkPublic(path.join(contentDir, 'public'), '');
|
|
266
|
+
publicCss.sort();
|
|
267
|
+
publicJs.sort();
|
|
268
|
+
|
|
269
|
+
return { css, js, publicCss, publicJs };
|
|
270
|
+
}
|
|
@@ -56,6 +56,7 @@ import fs from 'node:fs';
|
|
|
56
56
|
import path from 'node:path';
|
|
57
57
|
import { fileURLToPath } from 'node:url';
|
|
58
58
|
import { loadDocsConfig, collectConfiguredAssetPaths } from './config.ts';
|
|
59
|
+
import { resolveOutsidePublic } from './asset-path.js';
|
|
59
60
|
|
|
60
61
|
const MIME_BY_EXTENSION = {
|
|
61
62
|
'.svg': 'image/svg+xml',
|
|
@@ -76,24 +77,6 @@ function mimeFor(filePath) {
|
|
|
76
77
|
return MIME_BY_EXTENSION[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
/** Resolves a root-relative `urlPath` (e.g. "/images/hero.svg") against
|
|
80
|
-
* `contentDir` directly - not `<contentDir>/public` - the fallback
|
|
81
|
-
* location for one of the styles/footer/seo asset fields
|
|
82
|
-
* (collectConfiguredAssetPaths()) when it isn't sitting under public/.
|
|
83
|
-
* Segment-by-segment path-traversal guard (no
|
|
84
|
-
* `..`/`.` segments) since `urlPath` ultimately comes from an incoming
|
|
85
|
-
* request URL in the dev-server case, not just trusted writedocs.json
|
|
86
|
-
* content. Returns an absolute path, or null if nothing real is there. */
|
|
87
|
-
function resolveOutsidePublic(urlPath, contentDir) {
|
|
88
|
-
const segments = urlPath.replace(/^\/+/, '').split('/');
|
|
89
|
-
if (segments.some((segment) => segment === '..' || segment === '.' || segment === '')) return null;
|
|
90
|
-
const absolute = path.join(contentDir, ...segments);
|
|
91
|
-
try {
|
|
92
|
-
return fs.statSync(absolute).isFile() ? absolute : null;
|
|
93
|
-
} catch {
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
80
|
|
|
98
81
|
function publicPathFor(urlPath, contentDir) {
|
|
99
82
|
const segments = urlPath.replace(/^\/+/, '').split('/');
|
|
@@ -18,12 +18,13 @@ import fs from 'node:fs';
|
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import matter from 'gray-matter';
|
|
20
20
|
import { iconExists } from './icons.js';
|
|
21
|
+
import { readJsonText } from './config-file.js';
|
|
21
22
|
import { fileIdForPath } from './pages.js';
|
|
22
23
|
import { Notes, LANGUAGE_NAMES } from './mintlify-convert.js';
|
|
23
24
|
import { pageUrlResolver } from './link-check.js';
|
|
24
25
|
|
|
25
26
|
export function loadLegacyConfig(file) {
|
|
26
|
-
return JSON.parse(
|
|
27
|
+
return JSON.parse(readJsonText(file));
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
const PAGE_ENDINGS = ['.api.mdx', '.info.mdx', '.mdx', '.md'];
|
|
@@ -271,7 +271,11 @@ interface Props {
|
|
|
271
271
|
|
|
272
272
|
const rawProps = Astro.props as Props;
|
|
273
273
|
if (rawProps.redirectTo) {
|
|
274
|
-
|
|
274
|
+
// 301, not Astro's default 302: in a static build the status only picks
|
|
275
|
+
// the meta refresh delay (astro/dist/core/routing/3xx.js) - 2 seconds of
|
|
276
|
+
// "Redirecting from..." text for a 302, none for a 301. Hosts that read
|
|
277
|
+
// _redirects get a real 301 for "/" too (write-redirects-file.js).
|
|
278
|
+
return Astro.redirect(rawProps.redirectTo, 301);
|
|
275
279
|
}
|
|
276
280
|
const {
|
|
277
281
|
entry,
|
|
@@ -365,6 +369,11 @@ const activePagePosition = flattenNav(activeSection.pages).findIndex((e) => e.sl
|
|
|
365
369
|
// plus the always-visible global dropdown list - see BaseLayout.astro
|
|
366
370
|
// for how each renders.
|
|
367
371
|
const selectors = buildSelectors(activeSection.path, hrefForSlug, activePagePosition);
|
|
372
|
+
// <html lang>: the `language` of the navigation level this page sits under,
|
|
373
|
+
// if any. A hidden page has no level of its own (activeSection is only a
|
|
374
|
+
// fallback for its chrome), so it keeps the default.
|
|
375
|
+
const languageSegment = isHidden ? undefined : activeSection.path.find((segment) => segment.kind === "language");
|
|
376
|
+
const pageLang = languageSegment ? (languageSegment.items[languageSegment.index] as { language: string }).language : undefined;
|
|
368
377
|
const globalDropdowns = buildGlobalDropdowns(
|
|
369
378
|
resolveGlobalDropdowns(config.navigation),
|
|
370
379
|
currentFileId,
|
|
@@ -473,6 +482,7 @@ const components = {
|
|
|
473
482
|
selectors={selectors}
|
|
474
483
|
globalDropdowns={globalDropdowns}
|
|
475
484
|
mode={pageMode}
|
|
485
|
+
lang={pageLang}
|
|
476
486
|
>
|
|
477
487
|
{showSidebar && <Sidebar slot="sidebar" navTree={navTree} hrefForSlug={hrefForSlug} currentSlug={currentFileId} />}
|
|
478
488
|
{
|
package/src/pages/llms.txt.ts
CHANGED
|
@@ -94,7 +94,11 @@ export const GET: APIRoute = async () => {
|
|
|
94
94
|
// hrefForSlug() in [...slug].astro for the HTML form, and
|
|
95
95
|
// [...slug].md.ts for the .md form (no trailing slash, "index.md"
|
|
96
96
|
// for the home page rather than the HTML convention's bare "/").
|
|
97
|
-
|
|
97
|
+
// An OpenAPI operation page has no .md form ([...slug].md.ts leaves
|
|
98
|
+
// it out - it renders from the spec, not prose), so it keeps its HTML
|
|
99
|
+
// URL even when the .md routes exist.
|
|
100
|
+
const hasMarkdownRoute = config.contextMenu && !entry.data.openapi;
|
|
101
|
+
const path_ = hasMarkdownRoute ? `/${slug}.md` : slug === 'index' ? '/' : `/${slug}/`;
|
|
98
102
|
const href = (siteUrl ?? '') + path_;
|
|
99
103
|
let description = truncateDescription(entry.data.description);
|
|
100
104
|
// Mirrors Mintlify's own behavior: an OpenAPI operation page's
|