@writedocs/generator 0.9.1 → 0.9.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/package.json +1 -1
- package/src/cli/rewrite-redirect-pages.js +2 -1
- package/src/cli/write-redirects-file.js +3 -1
- package/src/layout/BaseLayout.astro +5 -0
- package/src/layout/styles/base.css +44 -0
- package/src/lib/config.ts +19 -2
- package/src/lib/content-check.js +4 -6
- package/src/lib/pages.js +19 -4
- package/src/pages/[...slug].astro +12 -6
package/package.json
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Any delay: Astro waits 2 seconds on a 302's page (the automatic "/"), none on a 301's.
|
|
18
|
+
const REFRESH = /<meta http-equiv="refresh" content="\d+;\s*url=([^"]*)"\s*\/?>/i;
|
|
18
19
|
const ASTRO_REDIRECT = /<title>Redirecting to:/;
|
|
19
20
|
const CANONICAL = /<link rel="canonical" href="([^"]*)"\s*\/?>/i;
|
|
20
21
|
|
|
@@ -58,7 +58,9 @@ export function writeRedirectsFile(distDir, contentDir) {
|
|
|
58
58
|
if (!hasExplicitRootRedirect && fs.existsSync(indexHtmlPath)) {
|
|
59
59
|
const html = fs.readFileSync(indexHtmlPath, 'utf8');
|
|
60
60
|
const match = html.match(/<meta http-equiv="refresh" content="\d+;\s*url=([^"]+)"/i);
|
|
61
|
-
|
|
61
|
+
// 302, not 301: see [...slug].astro - a browser keeps a 301 for good,
|
|
62
|
+
// and "/" stops redirecting once the site gets a home page.
|
|
63
|
+
if (match) lines.push(`/ ${match[1]} 302`);
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
if (lines.length === 0) return;
|
|
@@ -566,6 +566,10 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
|
|
|
566
566
|
--wd-text-muted: #64748b;
|
|
567
567
|
--wd-border: #e2e8f0;
|
|
568
568
|
--wd-surface: #f8fafc;
|
|
569
|
+
/* The browser's own controls - the page's scrollbar, a code block's -
|
|
570
|
+
drawn for the theme on screen. Without it they stayed light in dark
|
|
571
|
+
mode. */
|
|
572
|
+
color-scheme: light;
|
|
569
573
|
/* Not mode-dependent (a site's chosen font doesn't change between
|
|
570
574
|
light/dark) - set once here rather than duplicated in the
|
|
571
575
|
:root[data-theme='dark'] block below. */
|
|
@@ -594,6 +598,7 @@ const fontExtraCss = [fontFaceCss, fontWeightCss].filter(Boolean).join('\n');
|
|
|
594
598
|
--wd-text-muted: #94a3b8;
|
|
595
599
|
--wd-border: #1e293b;
|
|
596
600
|
--wd-surface: #131a2b;
|
|
601
|
+
color-scheme: dark;
|
|
597
602
|
}
|
|
598
603
|
</style>
|
|
599
604
|
{/* styles.fonts: local/externally-hosted font(s) (fontFaceRule() in
|
|
@@ -244,3 +244,47 @@ img.wd-icon-img {
|
|
|
244
244
|
height: 1em;
|
|
245
245
|
object-fit: contain;
|
|
246
246
|
}
|
|
247
|
+
|
|
248
|
+
/* Scrollbars of the columns beside the page - the sidebar, the table of
|
|
249
|
+
contents, the mobile menu and its option lists: a thin pill in the
|
|
250
|
+
site's muted text color on no track, faint while the pointer is
|
|
251
|
+
elsewhere, clearer over the column, strongest on the pill itself.
|
|
252
|
+
Chromium and Safari draw it from the ::-webkit-scrollbar rules (the
|
|
253
|
+
standard scrollbar-color/-width would switch those off in Chromium, and
|
|
254
|
+
can't make it this thin); Firefox, which has only the standard ones,
|
|
255
|
+
gets its thin scrollbar in the same colors. */
|
|
256
|
+
:root {
|
|
257
|
+
--wd-scrollbar-thumb: color-mix(in srgb, var(--wd-text-muted) 22%, transparent);
|
|
258
|
+
--wd-scrollbar-thumb-near: color-mix(in srgb, var(--wd-text-muted) 45%, transparent);
|
|
259
|
+
--wd-scrollbar-thumb-active: color-mix(in srgb, var(--wd-text-muted) 70%, transparent);
|
|
260
|
+
}
|
|
261
|
+
:is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel)::-webkit-scrollbar {
|
|
262
|
+
width: 10px;
|
|
263
|
+
height: 10px;
|
|
264
|
+
}
|
|
265
|
+
:is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel)::-webkit-scrollbar-track {
|
|
266
|
+
background: transparent;
|
|
267
|
+
}
|
|
268
|
+
:is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel)::-webkit-scrollbar-thumb {
|
|
269
|
+
background: var(--wd-scrollbar-thumb);
|
|
270
|
+
/* A 6px pill with 2px of air on each side - off the column's border line. */
|
|
271
|
+
border: 2px solid transparent;
|
|
272
|
+
background-clip: padding-box;
|
|
273
|
+
border-radius: 999px;
|
|
274
|
+
}
|
|
275
|
+
:is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel):hover::-webkit-scrollbar-thumb {
|
|
276
|
+
background: var(--wd-scrollbar-thumb-near);
|
|
277
|
+
}
|
|
278
|
+
:is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel)::-webkit-scrollbar-thumb:hover,
|
|
279
|
+
:is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel)::-webkit-scrollbar-thumb:active {
|
|
280
|
+
background: var(--wd-scrollbar-thumb-active);
|
|
281
|
+
}
|
|
282
|
+
@supports not selector(::-webkit-scrollbar) {
|
|
283
|
+
:is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel) {
|
|
284
|
+
scrollbar-width: thin;
|
|
285
|
+
scrollbar-color: var(--wd-scrollbar-thumb) transparent;
|
|
286
|
+
}
|
|
287
|
+
:is(.wd-sidebar, .wd-toc, .wd-mobile-menu-scroll, .wd-mobile-accordion-options-panel):hover {
|
|
288
|
+
scrollbar-color: var(--wd-scrollbar-thumb-near) transparent;
|
|
289
|
+
}
|
|
290
|
+
}
|
package/src/lib/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { EXCLUDED_TOP_LEVEL_DIRS } from './pages.js';
|
|
3
|
+
import { EXCLUDED_TOP_LEVEL_DIRS, navPageId } from './pages.js';
|
|
4
4
|
import matter from 'gray-matter';
|
|
5
5
|
import { writedocsTempDir } from './writedocs-temp-dir.js';
|
|
6
6
|
import { readableTextOn } from './color.js';
|
|
@@ -419,10 +419,27 @@ export function loadDocsConfig(contentDir: string): DocsConfig {
|
|
|
419
419
|
// A no-op pass over ordinary navigation trees (no openapi groups) -
|
|
420
420
|
// always run, rather than gated behind a global manifest check, since
|
|
421
421
|
// there's no longer a single global spec to check for.
|
|
422
|
-
result.data.navigation = expandOpenApiInNavigation(result.data.navigation, contentDir);
|
|
422
|
+
result.data.navigation = withPageIds(expandOpenApiInNavigation(result.data.navigation, contentDir));
|
|
423
423
|
return result.data;
|
|
424
424
|
}
|
|
425
425
|
|
|
426
|
+
/** The navigation with every page entry as its page id - "guides/index"
|
|
427
|
+
* becomes "guides" (navPageId() in lib/pages.js), so everything that
|
|
428
|
+
* matches entries against pages (routes, the sidebar, prev/next, llms.txt,
|
|
429
|
+
* the MCP index) finds a folder's index.mdx however writedocs.json names it. */
|
|
430
|
+
function withPageIds<T>(node: T): T {
|
|
431
|
+
if (Array.isArray(node)) return node.map((item) => (typeof item === 'string' ? navPageId(item) : withPageIds(item))) as T;
|
|
432
|
+
if (node && typeof node === 'object') {
|
|
433
|
+
return Object.fromEntries(
|
|
434
|
+
Object.entries(node).map(([key, value]) => [
|
|
435
|
+
key,
|
|
436
|
+
key === 'page' && typeof value === 'string' ? navPageId(value) : key === 'href' || key === 'openapi' ? value : withPageIds(value),
|
|
437
|
+
])
|
|
438
|
+
) as T;
|
|
439
|
+
}
|
|
440
|
+
return node;
|
|
441
|
+
}
|
|
442
|
+
|
|
426
443
|
// --- Locating a page by file id, independent of its effective slug -----
|
|
427
444
|
//
|
|
428
445
|
// writedocs.json's `pages` arrays, and every helper above/below that walks
|
package/src/lib/content-check.js
CHANGED
|
@@ -30,7 +30,7 @@ import fs from 'node:fs';
|
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
import matter from 'gray-matter';
|
|
32
32
|
import { visit } from 'unist-util-visit';
|
|
33
|
-
import { findAllPages, fileIdForPath, titleFromPath } from './pages.js';
|
|
33
|
+
import { findAllPages, fileIdForPath, titleFromPath, navPageId } from './pages.js';
|
|
34
34
|
import { iconExists } from './icons.js';
|
|
35
35
|
import { findUnknownComponents } from './mdx-unknown-components.js';
|
|
36
36
|
import { findInteractiveComponents, simplifiedBuiltinMessage } from './mdx-inline-react.js';
|
|
@@ -400,16 +400,14 @@ export async function checkContent(contentDir, configText) {
|
|
|
400
400
|
const locate = createJsonLocator(configText);
|
|
401
401
|
const ids = new Set(pages.map(fileIdForPath));
|
|
402
402
|
for (const ref of navigationReferences(config.navigation)) {
|
|
403
|
-
|
|
404
|
-
|
|
403
|
+
// "guides/index" and "guides" both name guides/index.mdx (navPageId()).
|
|
404
|
+
if (ids.has(navPageId(ref.id))) continue;
|
|
405
405
|
errors.push(
|
|
406
406
|
issue(
|
|
407
407
|
'writedocs.json',
|
|
408
408
|
locate(ref.path)?.line,
|
|
409
409
|
`The navigation lists page "${ref.id}", but there's no page with that path.`,
|
|
410
|
-
|
|
411
|
-
? `A folder's index page is listed by the folder's path - write "${ref.id.replace(/\/index$/, '')}".`
|
|
412
|
-
: `Create ${ref.id}.mdx, or fix the path (it's relative to the folder writedocs.json is in, without the extension).`
|
|
410
|
+
`Create ${ref.id}.mdx, or fix the path (it's relative to the folder writedocs.json is in, without the extension).`
|
|
413
411
|
)
|
|
414
412
|
);
|
|
415
413
|
}
|
package/src/lib/pages.js
CHANGED
|
@@ -16,9 +16,16 @@ export const EXCLUDED_TOP_LEVEL_DIRS = new Set(['node_modules', 'dist', '.astro'
|
|
|
16
16
|
/** A page's title when its frontmatter has none - Mintlify's rule, so a
|
|
17
17
|
* migrated page that relied on it keeps the same title: the file name
|
|
18
18
|
* without its extension, dashes and underscores as spaces, first letter
|
|
19
|
-
* capitalized. `guides/getting-started.mdx` -> "Getting started".
|
|
19
|
+
* capitalized. `guides/getting-started.mdx` -> "Getting started". A
|
|
20
|
+
* folder's index page is named for the folder (`api/index.mdx` -> "Api"),
|
|
21
|
+
* and the site's own index.mdx is "Home" - "Index" names neither. */
|
|
20
22
|
export function titleFromPath(filePath) {
|
|
21
|
-
const
|
|
23
|
+
const parts = String(filePath).split(/[\\/]/).filter(Boolean);
|
|
24
|
+
let base = (parts.pop() ?? '').replace(/\.mdx?$/i, '');
|
|
25
|
+
if (base.toLowerCase() === 'index') {
|
|
26
|
+
if (parts.length === 0) return 'Home';
|
|
27
|
+
base = parts.pop();
|
|
28
|
+
}
|
|
22
29
|
const words = base.replace(/[-_]+/g, ' ').trim();
|
|
23
30
|
return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Untitled';
|
|
24
31
|
}
|
|
@@ -61,15 +68,23 @@ function readIgnoreFile(contentDir) {
|
|
|
61
68
|
* groups' `page`) - read straight from the file, since this runs before
|
|
62
69
|
* (and independently of) config validation. Empty when there's no
|
|
63
70
|
* readable writedocs.json. */
|
|
71
|
+
/** The page id a navigation entry names. "guides/index" and "guides" are
|
|
72
|
+
* the same page: a folder's index.mdx has the folder's id (fileIdForPath()
|
|
73
|
+
* below), and Mintlify projects write it either way. The root "index"
|
|
74
|
+
* stays as it is. */
|
|
75
|
+
export function navPageId(id) {
|
|
76
|
+
return id.endsWith('/index') ? id.slice(0, -'/index'.length) : id;
|
|
77
|
+
}
|
|
78
|
+
|
|
64
79
|
/** Every page id a `navigation` lists, in the order a reader meets them -
|
|
65
80
|
* top to bottom, each group's own `page` before its `pages` - without
|
|
66
81
|
* repeats. */
|
|
67
82
|
export function navigationPageOrder(navigation) {
|
|
68
83
|
const ids = new Set();
|
|
69
84
|
(function walk(node) {
|
|
70
|
-
if (Array.isArray(node)) node.forEach((item) => (typeof item === 'string' ? ids.add(item) : walk(item)));
|
|
85
|
+
if (Array.isArray(node)) node.forEach((item) => (typeof item === 'string' ? ids.add(navPageId(item)) : walk(item)));
|
|
71
86
|
else if (node && typeof node === 'object') {
|
|
72
|
-
if (typeof node.page === 'string') ids.add(node.page);
|
|
87
|
+
if (typeof node.page === 'string') ids.add(navPageId(node.page));
|
|
73
88
|
for (const [key, value] of Object.entries(node)) {
|
|
74
89
|
if (key !== 'page' && key !== 'openapi' && key !== 'href') walk(value);
|
|
75
90
|
}
|
|
@@ -158,7 +158,7 @@ export async function getStaticPaths() {
|
|
|
158
158
|
// rootAlreadyClaimed rules out), so the ternary below always resolves
|
|
159
159
|
// to the non-root branch.
|
|
160
160
|
const rootRedirect = firstEntry
|
|
161
|
-
? [{ params: { slug: undefined }, props: { redirectTo: `/${normalizeEntryId(firstEntry.id)}
|
|
161
|
+
? [{ params: { slug: undefined }, props: { redirectTo: `/${normalizeEntryId(firstEntry.id)}/`, temporary: true } }]
|
|
162
162
|
: [];
|
|
163
163
|
|
|
164
164
|
// Tracks every file id a nav-driven route below already claims, so
|
|
@@ -259,6 +259,8 @@ export async function getStaticPaths() {
|
|
|
259
259
|
|
|
260
260
|
interface Props {
|
|
261
261
|
redirectTo?: string;
|
|
262
|
+
// The automatic "/" redirect: temporary, see below.
|
|
263
|
+
temporary?: boolean;
|
|
262
264
|
entry?: DocsEntry;
|
|
263
265
|
prev?: { slug: string; group: string | null } | null;
|
|
264
266
|
next?: { slug: string; group: string | null } | null;
|
|
@@ -274,11 +276,15 @@ interface Props {
|
|
|
274
276
|
|
|
275
277
|
const rawProps = Astro.props as Props;
|
|
276
278
|
if (rawProps.redirectTo) {
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// "
|
|
280
|
-
//
|
|
281
|
-
|
|
279
|
+
// The automatic "/" redirect is temporary (302): its target is just the
|
|
280
|
+
// navigation's first page, and it goes away once the site has a page at
|
|
281
|
+
// "/". A 301 is remembered by the browser for good - after that, "/" kept
|
|
282
|
+
// redirecting even with a home page in place, in `writedocs dev` (every
|
|
283
|
+
// project's preview shares localhost:4321) and for returning visitors.
|
|
284
|
+
// A frontmatter `url` page stays 301. In a static build the status only
|
|
285
|
+
// picks the refresh delay of Astro's redirect page, and the build rewrites
|
|
286
|
+
// those pages to redirect at once either way (rewrite-redirect-pages.js).
|
|
287
|
+
return Astro.redirect(rawProps.redirectTo, rawProps.temporary ? 302 : 301);
|
|
282
288
|
}
|
|
283
289
|
const {
|
|
284
290
|
entry,
|