@writedocs/generator 0.9.4 → 0.10.1
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 -0
- package/package.json +1 -1
- package/src/cli/dev.js +72 -10
- package/src/cli/page-list-watch.js +66 -0
- package/src/cli/run-pagefind.js +72 -1
- package/src/cli/write-redirects-file.js +33 -0
- package/src/layout/components/MobileMenu.astro +2 -2
- package/src/layout/components/TopBar.astro +1 -1
- package/src/lib/config-reload-integration.js +32 -0
- package/src/lib/config-schema.js +10 -1
- package/src/lib/config-schema.ts +21 -6
- package/src/lib/config.ts +22 -7
- package/src/lib/content-check.js +14 -0
- package/src/lib/folder-redirects.js +47 -0
- package/src/lib/hidden-sections.js +58 -0
- package/src/lib/json-schema-descriptions.js +6 -0
- package/src/lib/link-check.js +7 -1
- package/src/lib/selector-placement.js +4 -1
- package/src/pages/[...slug].astro +31 -4
- package/src/scripts/search.ts +4 -1
- package/writedocs.schema.json +350 -0
package/astro.config.mjs
CHANGED
|
@@ -37,7 +37,9 @@ import { remarkExtractInlineReactComponents } from './src/lib/mdx-inline-react.j
|
|
|
37
37
|
import { writedocsTempDir, writedocsBuildStagingDir } from './src/lib/writedocs-temp-dir.js';
|
|
38
38
|
import { stylesAssetFallback } from './src/lib/styles-asset-integration.js';
|
|
39
39
|
import { mcpDevServer } from './src/lib/mcp-dev-integration.js';
|
|
40
|
+
import { configReload } from './src/lib/config-reload-integration.js';
|
|
40
41
|
import { canonicalPath } from './src/lib/canonical-path.js';
|
|
42
|
+
import { folderAddresses } from './src/lib/folder-redirects.js';
|
|
41
43
|
import { report } from './src/lib/cli-report.js';
|
|
42
44
|
import {
|
|
43
45
|
loadDocsConfig,
|
|
@@ -152,12 +154,14 @@ function collectNoindexIds(rootContentDir) {
|
|
|
152
154
|
// redirect-only route [...slug].astro synthesizes (noindex, pointing at
|
|
153
155
|
// the first page) - which doesn't belong in the sitemap either.
|
|
154
156
|
let rootIsPage = false;
|
|
157
|
+
const pageIds = [];
|
|
155
158
|
|
|
156
159
|
for (const relativeId of findAllPages(rootContentDir)) {
|
|
157
160
|
const file = path.join(rootContentDir, relativeId);
|
|
158
161
|
const { data } = matter(fs.readFileSync(file, 'utf-8'));
|
|
159
162
|
const pageId = normalizeEntryId(data?.slug ?? relativeId.replace(/\.mdx?$/i, '').replace(/\/index$/, ''));
|
|
160
163
|
if (pageId === 'index') rootIsPage = true;
|
|
164
|
+
pageIds.push(pageId);
|
|
161
165
|
// Top-level `noindex` is Mintlify's spelling, and a Mintlify `hidden`
|
|
162
166
|
// page is noindexed too - content.config.ts folds both into
|
|
163
167
|
// `seo.noindex` the same way (an explicit value wins).
|
|
@@ -172,9 +176,13 @@ function collectNoindexIds(rootContentDir) {
|
|
|
172
176
|
for (const file of walkMdFiles(generatedDocsDir)) {
|
|
173
177
|
const { data } = matter(fs.readFileSync(file, 'utf-8'));
|
|
174
178
|
if (data?.slug && normalizeEntryId(data.slug) === 'index') rootIsPage = true;
|
|
179
|
+
if (data?.slug) pageIds.push(normalizeEntryId(data.slug));
|
|
175
180
|
if ((data?.seo?.noindex ?? data?.noindex ?? data?.hidden === true) && data.slug) ids.add(normalizeEntryId(data.slug));
|
|
176
181
|
}
|
|
177
182
|
if (!rootIsPage) ids.add('index');
|
|
183
|
+
// Folder addresses with no page of their own redirect to one under them
|
|
184
|
+
// (lib/folder-redirects.js) - redirects, so not in the sitemap either.
|
|
185
|
+
for (const folder of folderAddresses(pageIds)) ids.add(folder);
|
|
178
186
|
return ids;
|
|
179
187
|
}
|
|
180
188
|
|
|
@@ -317,6 +325,8 @@ export default defineConfig({
|
|
|
317
325
|
// /mcp in `writedocs dev`, as the build's dist/_worker.js serves it -
|
|
318
326
|
// see src/lib/mcp-dev-integration.js.
|
|
319
327
|
mcpDevServer({ version: JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8')).version }),
|
|
328
|
+
// writedocs dev: an edit to writedocs.json reloads the open tab.
|
|
329
|
+
configReload({ configPath: path.join(contentDir, 'writedocs.json') }),
|
|
320
330
|
],
|
|
321
331
|
output: 'static',
|
|
322
332
|
// Dual Shiki themes for fenced code blocks (```) in MDX content, so
|
package/package.json
CHANGED
package/src/cli/dev.js
CHANGED
|
@@ -8,6 +8,7 @@ import { reportApiPages } from './api-pages-output.js';
|
|
|
8
8
|
import { runningPreview, writeLock, removeLock } from './dev-lock.js';
|
|
9
9
|
import { showUpdateNotice } from './update-check.js';
|
|
10
10
|
import { shouldOpenBrowser, openBrowser } from './open-browser.js';
|
|
11
|
+
import { watchPageList } from './page-list-watch.js';
|
|
11
12
|
|
|
12
13
|
// The same problem tends to arrive more than once in a row - Vite and
|
|
13
14
|
// Astro each log a failed page, and a page compiles for more than one
|
|
@@ -39,9 +40,13 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false, o
|
|
|
39
40
|
reportApiPages(api);
|
|
40
41
|
api.warnings.forEach((message) => log.warn(message));
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
let starting = step('Starting local preview');
|
|
43
44
|
let ready = false;
|
|
44
45
|
let browserOpened = false;
|
|
46
|
+
// Restarting after pages were added or removed (see the watcher below):
|
|
47
|
+
// when that restart began, and the port to come back on.
|
|
48
|
+
let restartStarted = null;
|
|
49
|
+
let currentPort = port;
|
|
45
50
|
|
|
46
51
|
const lastShown = new Map();
|
|
47
52
|
const once = (key, print) => {
|
|
@@ -82,11 +87,7 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false, o
|
|
|
82
87
|
|
|
83
88
|
let fatal = null;
|
|
84
89
|
const rawTail = [];
|
|
85
|
-
const
|
|
86
|
-
packageRoot,
|
|
87
|
-
contentDir,
|
|
88
|
-
port,
|
|
89
|
-
onEvent(event) {
|
|
90
|
+
const onEvent = (event) => {
|
|
90
91
|
if (verbose) {
|
|
91
92
|
const line = verboseLine(event);
|
|
92
93
|
if (line !== null) log.line(color.dim(line));
|
|
@@ -94,11 +95,24 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false, o
|
|
|
94
95
|
switch (event.type) {
|
|
95
96
|
case 'ready': {
|
|
96
97
|
ready = true;
|
|
97
|
-
starting.succeed(`Preview ready ${color.dim(`in ${duration(Date.now() - started)}`)}`);
|
|
98
98
|
const [local] = event.urls?.local ?? [];
|
|
99
99
|
writeLock(packageRoot, { contentDir, url: local ?? null });
|
|
100
100
|
const wanted = Number(port ?? 4321);
|
|
101
101
|
const actual = local ? Number(new URL(local).port) : wanted;
|
|
102
|
+
currentPort = actual;
|
|
103
|
+
// Pages changed while it was starting: restart now that it's up.
|
|
104
|
+
if (restartPending) {
|
|
105
|
+
const change = restartPending;
|
|
106
|
+
restartPending = false;
|
|
107
|
+
setTimeout(() => restartForPages(change), 0);
|
|
108
|
+
}
|
|
109
|
+
// Back after a restart: one line - the open tab reloads by itself.
|
|
110
|
+
if (restartStarted !== null) {
|
|
111
|
+
starting.succeed(`Preview updated ${color.dim(`in ${duration(Date.now() - restartStarted)}`)}`);
|
|
112
|
+
restartStarted = null;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
starting.succeed(`Preview ready ${color.dim(`in ${duration(Date.now() - started)}`)}`);
|
|
102
116
|
log.line();
|
|
103
117
|
log.line(` ${color.bold('Local:')} ${color.cyan(local ?? `http://localhost:${wanted}/`)}`);
|
|
104
118
|
if (actual !== wanted) log.line(color.dim(` Port ${wanted} was in use, so the preview is on ${actual}.`));
|
|
@@ -129,6 +143,14 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false, o
|
|
|
129
143
|
if (rawTail.length > 40) rawTail.shift();
|
|
130
144
|
return;
|
|
131
145
|
case 'astro-log': {
|
|
146
|
+
// A page file just deleted: Astro's list of page modules
|
|
147
|
+
// (.astro/content-modules.mjs) still points at it for a moment -
|
|
148
|
+
// "Failed to load url ... Does the file exist?" - until the restart
|
|
149
|
+
// the page-list watcher starts brings in a fresh list. Noise, not a
|
|
150
|
+
// problem in the site. Every other error still shows, and a
|
|
151
|
+
// preview that can't start still reports through 'fatal'.
|
|
152
|
+
if (event.level === 'error' && /content-modules\.mjs/.test(event.message) && /Does the file exist/.test(event.message)) return;
|
|
153
|
+
if (restartStarted !== null && event.level === 'error') return;
|
|
132
154
|
if (event.level === 'error') {
|
|
133
155
|
showError(describeError(stripAnsi(event.message), contentDir, { root: packageRoot }));
|
|
134
156
|
return;
|
|
@@ -145,7 +167,36 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false, o
|
|
|
145
167
|
return;
|
|
146
168
|
}
|
|
147
169
|
}
|
|
148
|
-
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Pages added or removed while the preview runs: its page list is read
|
|
173
|
+
// when it starts, so it restarts - on the same port; the open tab
|
|
174
|
+
// reconnects and reloads by itself. Edits to pages and to writedocs.json
|
|
175
|
+
// don't need this (lib/config-reload-integration.js).
|
|
176
|
+
let child = null;
|
|
177
|
+
let restarting = false;
|
|
178
|
+
let restartPending = false;
|
|
179
|
+
const restartForPages = ({ added, removed }) => {
|
|
180
|
+
const what = [
|
|
181
|
+
...added.slice(0, 3).map((file) => `${file} added`),
|
|
182
|
+
...removed.slice(0, 3).map((file) => `${file} removed`),
|
|
183
|
+
];
|
|
184
|
+
const more = added.length + removed.length - what.length;
|
|
185
|
+
log.info(`${what.join(', ')}${more > 0 ? ` and ${more} more` : ''} - restarting the preview so the page list is current.`);
|
|
186
|
+
restarting = true;
|
|
187
|
+
ready = false;
|
|
188
|
+
restartStarted = Date.now();
|
|
189
|
+
starting = step('Restarting the preview');
|
|
190
|
+
child?.kill();
|
|
191
|
+
};
|
|
192
|
+
const pageWatch = watchPageList(contentDir, (change) => {
|
|
193
|
+
if (stopping || restarting) return;
|
|
194
|
+
// Still starting: restart once it's up, not halfway.
|
|
195
|
+
if (!ready) {
|
|
196
|
+
restartPending = change;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
restartForPages(change);
|
|
149
200
|
});
|
|
150
201
|
|
|
151
202
|
// Ctrl+C: stop the server quietly. (The child gets the same signal from
|
|
@@ -155,12 +206,23 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false, o
|
|
|
155
206
|
const stop = () => {
|
|
156
207
|
if (stopping) process.exit(130);
|
|
157
208
|
stopping = true;
|
|
158
|
-
|
|
209
|
+
pageWatch.close();
|
|
210
|
+
child?.kill();
|
|
159
211
|
};
|
|
160
212
|
process.on('SIGINT', stop);
|
|
161
213
|
process.on('SIGTERM', stop);
|
|
162
214
|
|
|
163
|
-
|
|
215
|
+
let code;
|
|
216
|
+
for (;;) {
|
|
217
|
+
fatal = null;
|
|
218
|
+
rawTail.length = 0;
|
|
219
|
+
const run = runAstro('dev', { packageRoot, contentDir, port: currentPort, onEvent });
|
|
220
|
+
child = run.child;
|
|
221
|
+
code = await run.exited;
|
|
222
|
+
if (stopping || !restarting) break;
|
|
223
|
+
restarting = false;
|
|
224
|
+
}
|
|
225
|
+
pageWatch.close();
|
|
164
226
|
if (stopping) {
|
|
165
227
|
if (ready) log.line(color.dim('Preview stopped.'));
|
|
166
228
|
return;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// `writedocs dev`: noticing pages added or removed. The preview's list of page
|
|
2
|
+
// files is read once, when it starts (content.config.ts hands findAllPages()'s
|
|
3
|
+
// list to Astro) - an edit to a page shows up at once, but a new page stays
|
|
4
|
+
// "not found" and a deleted one keeps being served. So this watches the
|
|
5
|
+
// content folder, and when the list findAllPages() gives changes, calls
|
|
6
|
+
// `onChange` - cli/dev.js restarts the preview then.
|
|
7
|
+
//
|
|
8
|
+
// It compares the list itself, not file events: a Markdown file that isn't
|
|
9
|
+
// a page (no frontmatter, not in the navigation), an edit to a page, or a
|
|
10
|
+
// save that writes a file twice doesn't restart anything.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import { findAllPages, EXCLUDED_TOP_LEVEL_DIRS } from '../lib/pages.js';
|
|
13
|
+
|
|
14
|
+
/** Wait this long after the last file event before checking - a rename, a
|
|
15
|
+
* copy of several files or an editor's save arrive as a burst of events. */
|
|
16
|
+
export const SETTLE_MS = 400;
|
|
17
|
+
|
|
18
|
+
/** Whether a changed file can change the page list: a .md/.mdx file, or
|
|
19
|
+
* writedocs.json (it lists pages that have no frontmatter), outside the
|
|
20
|
+
* folders findAllPages() never looks in. */
|
|
21
|
+
export function mayChangePages(relative) {
|
|
22
|
+
if (!relative) return true;
|
|
23
|
+
const parts = String(relative).split(/[\\/]/);
|
|
24
|
+
if (EXCLUDED_TOP_LEVEL_DIRS.has(parts[0]) || parts.some((part) => part.startsWith('.'))) return false;
|
|
25
|
+
const name = parts[parts.length - 1];
|
|
26
|
+
return /\.mdx?$/i.test(name) || name === 'writedocs.json';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Starts watching; returns `{ close }`. `onChange({ added, removed })` gets
|
|
30
|
+
* the page files (relative paths) that came and went. */
|
|
31
|
+
export function watchPageList(contentDir, onChange, { settleMs = SETTLE_MS } = {}) {
|
|
32
|
+
let current = new Set(findAllPages(contentDir));
|
|
33
|
+
let timer = null;
|
|
34
|
+
const check = () => {
|
|
35
|
+
let next;
|
|
36
|
+
try {
|
|
37
|
+
next = new Set(findAllPages(contentDir));
|
|
38
|
+
} catch {
|
|
39
|
+
return; // mid-save, or a folder being moved - the next event checks again
|
|
40
|
+
}
|
|
41
|
+
const added = [...next].filter((file) => !current.has(file));
|
|
42
|
+
const removed = [...current].filter((file) => !next.has(file));
|
|
43
|
+
if (!added.length && !removed.length) return;
|
|
44
|
+
current = next;
|
|
45
|
+
onChange({ added, removed });
|
|
46
|
+
};
|
|
47
|
+
let watcher;
|
|
48
|
+
try {
|
|
49
|
+
watcher = fs.watch(contentDir, { recursive: true }, (_event, filename) => {
|
|
50
|
+
if (!mayChangePages(filename)) return;
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
timer = setTimeout(check, settleMs);
|
|
53
|
+
});
|
|
54
|
+
watcher.on('error', () => {});
|
|
55
|
+
} catch {
|
|
56
|
+
// A file system without recursive watching: pages added while `dev`
|
|
57
|
+
// runs need a restart, as before.
|
|
58
|
+
return { close() {} };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
close() {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
watcher.close();
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/cli/run-pagefind.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Locates the installed pagefind package by walking up node_modules
|
|
@@ -53,7 +54,77 @@ function resolvePagefindBin(packageRoot) {
|
|
|
53
54
|
* Pagefind's output is captured, not printed: the CLI prints its own step
|
|
54
55
|
* line, and Pagefind's text only when it fails (in the rejected Error).
|
|
55
56
|
*/
|
|
56
|
-
export function runPagefind(siteDir, { packageRoot }) {
|
|
57
|
+
export async function runPagefind(siteDir, { packageRoot }) {
|
|
58
|
+
const groups = searchSpaces(siteDir);
|
|
59
|
+
if (groups) return indexBySpace(siteDir, packageRoot, groups);
|
|
60
|
+
return indexWholeSite(siteDir, packageRoot);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Pages under a hidden navigation item name their search space in their
|
|
64
|
+
// markup ([...slug].astro, lib/hidden-sections.js).
|
|
65
|
+
const SPACE = /data-wd-search-space="([^"]+)"/;
|
|
66
|
+
const SEARCH_PUBLIC = /data-wd-search-public="true"/;
|
|
67
|
+
|
|
68
|
+
/** The site's pages by search space - `{ public, spaces }` - or null when
|
|
69
|
+
* no page is in a hidden space (the whole site is one index, as ever).
|
|
70
|
+
* Only pages Pagefind would index: those with a data-pagefind-body. */
|
|
71
|
+
function searchSpaces(siteDir) {
|
|
72
|
+
const publicPages = [];
|
|
73
|
+
const spaces = new Map();
|
|
74
|
+
const walk = (dir) => {
|
|
75
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
76
|
+
const full = path.join(dir, entry.name);
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
if (!['_astro', 'pagefind', 'pagefind-spaces'].includes(entry.name)) walk(full);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!entry.name.endsWith('.html')) continue;
|
|
82
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
83
|
+
if (!content.includes('data-pagefind-body')) continue;
|
|
84
|
+
const page = { rel: path.relative(siteDir, full).split(path.sep).join('/'), content };
|
|
85
|
+
const space = SPACE.exec(content)?.[1];
|
|
86
|
+
if (!space) {
|
|
87
|
+
publicPages.push(page);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const group = spaces.get(space) ?? { searchPublic: false, pages: [] };
|
|
91
|
+
group.searchPublic ||= SEARCH_PUBLIC.test(content);
|
|
92
|
+
group.pages.push(page);
|
|
93
|
+
spaces.set(space, group);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
walk(siteDir);
|
|
97
|
+
return spaces.size ? { public: publicPages, spaces } : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One index for the public pages (dist/pagefind/) and one per hidden
|
|
101
|
+
* space (dist/pagefind-spaces/<id>/) - so the public search never finds a
|
|
102
|
+
* hidden page, and a hidden space's search finds only its own pages, plus
|
|
103
|
+
* the public ones when it sets `searchPublic`. src/scripts/search.ts loads
|
|
104
|
+
* the index of the page's space. */
|
|
105
|
+
async function indexBySpace(siteDir, packageRoot, groups) {
|
|
106
|
+
const pagefind = await import(pathToFileURL(path.join(findPagefindDir(packageRoot), 'lib', 'index.js')).href);
|
|
107
|
+
const check = (result, what) => {
|
|
108
|
+
if (result.errors?.length) throw new Error(`pagefind (${what}): ${result.errors.join('; ')}`);
|
|
109
|
+
return result;
|
|
110
|
+
};
|
|
111
|
+
const write = async (pages, outputPath, what) => {
|
|
112
|
+
const { index } = check(await pagefind.createIndex({}), what);
|
|
113
|
+
for (const page of pages) check(await index.addHTMLFile({ sourcePath: page.rel, content: page.content }), `${what}: ${page.rel}`);
|
|
114
|
+
check(await index.writeFiles({ outputPath }), what);
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
await write(groups.public, path.join(siteDir, 'pagefind'), 'public pages');
|
|
118
|
+
for (const [id, space] of groups.spaces) {
|
|
119
|
+
const pages = space.searchPublic ? [...space.pages, ...groups.public] : space.pages;
|
|
120
|
+
await write(pages, path.join(siteDir, 'pagefind-spaces', id), `hidden space ${id}`);
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
await pagefind.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function indexWholeSite(siteDir, packageRoot) {
|
|
57
128
|
const bin = resolvePagefindBin(packageRoot);
|
|
58
129
|
return new Promise((resolve, reject) => {
|
|
59
130
|
const child = spawn(process.execPath, [bin, '--site', siteDir, '--quiet'], {
|
|
@@ -63,6 +63,17 @@ export function writeRedirectsFile(distDir, contentDir) {
|
|
|
63
63
|
if (match) lines.push(`/ ${match[1]} 302`);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Folder addresses redirecting to the first page under them
|
|
67
|
+
// (lib/folder-redirects.js). Read back from the built pages like "/"
|
|
68
|
+
// above: Astro writes a temporary (302) redirect's page with a 2-second
|
|
69
|
+
// refresh and a permanent one's with 0 - writedocs.json's own redirects
|
|
70
|
+
// and `url` pages are permanent, so the 2-second ones are exactly the
|
|
71
|
+
// automatic folder redirects. (Read before rewrite-redirect-pages.js makes
|
|
72
|
+
// every redirect page go at once.)
|
|
73
|
+
for (const { url, target } of temporaryRedirectPages(distDir)) {
|
|
74
|
+
lines.push(`${url} ${target} 302`, `${url.replace(/\/$/, '')} ${target} 302`);
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
if (lines.length === 0) return;
|
|
67
78
|
|
|
68
79
|
const generated = [
|
|
@@ -81,3 +92,25 @@ export function writeRedirectsFile(distDir, contentDir) {
|
|
|
81
92
|
const existing = fs.existsSync(redirectsPath) ? fs.readFileSync(redirectsPath, 'utf8').replace(/\s*$/, '\n\n') : '';
|
|
82
93
|
fs.writeFileSync(redirectsPath, existing + generated);
|
|
83
94
|
}
|
|
95
|
+
|
|
96
|
+
/** Astro's pages for temporary redirects below the site's root, as
|
|
97
|
+
* `{ url: "/docs/creator/", target }`. */
|
|
98
|
+
function temporaryRedirectPages(distDir) {
|
|
99
|
+
const found = [];
|
|
100
|
+
const walk = (dir) => {
|
|
101
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
102
|
+
const full = path.join(dir, entry.name);
|
|
103
|
+
if (entry.isDirectory()) {
|
|
104
|
+
if (!['_astro', 'pagefind', 'pagefind-spaces'].includes(entry.name)) walk(full);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (entry.name !== 'index.html' || dir === distDir) continue;
|
|
108
|
+
const html = fs.readFileSync(full, 'utf8');
|
|
109
|
+
if (!html.includes('<title>Redirecting to:')) continue;
|
|
110
|
+
const match = html.match(/<meta http-equiv="refresh" content="2;\s*url=([^"]+)"/i);
|
|
111
|
+
if (match) found.push({ url: `/${path.relative(distDir, dir).split(path.sep).join('/')}/`, target: match[1] });
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
walk(distDir);
|
|
115
|
+
return found.sort((a, b) => a.url.localeCompare(b.url));
|
|
116
|
+
}
|
|
@@ -98,9 +98,9 @@ const showMenu = hasMobileMenu({ sidebar: showSidebarCol, selectors, globalDropd
|
|
|
98
98
|
</button>
|
|
99
99
|
</div>
|
|
100
100
|
<div class="wd-mobile-menu-scroll">
|
|
101
|
-
{(selectors.
|
|
101
|
+
{(selectors.some((sel) => !sel.hidden) || globalDropdowns.length > 0) && (
|
|
102
102
|
<div class="wd-mobile-menu-selectors">
|
|
103
|
-
{selectors.map((sel) => {
|
|
103
|
+
{selectors.filter((sel) => !sel.hidden).map((sel) => {
|
|
104
104
|
const current = sel.options.find((o) => o.active) ?? sel.options[0];
|
|
105
105
|
return (
|
|
106
106
|
<details class="wd-mobile-accordion" name="wd-mobile-accordion">
|
|
@@ -59,7 +59,7 @@ const { config, selectors, globalDropdowns, showSidebarCol, logoLight, logoDark,
|
|
|
59
59
|
const placements = selectorPlacements(selectors, { sidebar: showSidebarCol });
|
|
60
60
|
const showMenuButton = hasMobileMenu({ sidebar: showSidebarCol, selectors, globalDropdowns });
|
|
61
61
|
const switcherSelectors = selectors.filter((_, i) => placements[i] === 'topbar');
|
|
62
|
-
const tabSelectors = selectors.filter((
|
|
62
|
+
const tabSelectors = selectors.filter((_, i) => placements[i] === 'tabs');
|
|
63
63
|
// One row per level of tabs: a tab holding its own tabs gets a second row
|
|
64
64
|
// underneath, with that tab's tabs. The global dropdowns stay in the first
|
|
65
65
|
// row, which renders on its own when there are only those.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// `writedocs dev`: an edit to writedocs.json reloads the open tab. The server
|
|
2
|
+
// already renders with the new file on the next request (lib/config.ts reads
|
|
3
|
+
// it each time), but nothing told the browser - the tab kept the old
|
|
4
|
+
// navigation until reloaded by hand. writedocs.json sits in the content
|
|
5
|
+
// folder, outside what Vite watches, so it's added to the watcher here, and
|
|
6
|
+
// a change sends Vite's full-reload.
|
|
7
|
+
//
|
|
8
|
+
// Pages added or removed need more than a reload - see cli/page-list-watch.js.
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/** Wait this long after the last change before reloading - an editor's save
|
|
12
|
+
* can arrive as more than one write. */
|
|
13
|
+
export const RELOAD_DELAY_MS = 150;
|
|
14
|
+
|
|
15
|
+
export function configReload({ configPath }) {
|
|
16
|
+
return {
|
|
17
|
+
name: 'writedocs-config-reload',
|
|
18
|
+
hooks: {
|
|
19
|
+
'astro:server:setup': ({ server }) => {
|
|
20
|
+
const target = path.resolve(configPath);
|
|
21
|
+
const same = (file) => path.resolve(file).toLowerCase() === target.toLowerCase();
|
|
22
|
+
server.watcher.add(target);
|
|
23
|
+
let timer = null;
|
|
24
|
+
server.watcher.on('change', (file) => {
|
|
25
|
+
if (!same(file)) return;
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
timer = setTimeout(() => server.ws.send({ type: 'full-reload', path: '*' }), RELOAD_DELAY_MS);
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/lib/config-schema.js
CHANGED
|
@@ -32,7 +32,8 @@ const navLinkSchema = z.object({ label: z.string(), href: z.string() }).strict()
|
|
|
32
32
|
const navItemSchema = z.lazy(
|
|
33
33
|
() => z.union([navPageSchema, navGroupSchema, navLinkSchema])
|
|
34
34
|
);
|
|
35
|
-
function withChildren(
|
|
35
|
+
function withChildren(ownFields) {
|
|
36
|
+
const base = { ...ownFields, hidden: z.boolean().optional(), searchPublic: z.boolean().optional() };
|
|
36
37
|
return z.union([
|
|
37
38
|
z.object({ ...base, pages: z.array(navItemSchema) }).strict(),
|
|
38
39
|
z.object({ ...base, tabs: z.array(tabSchema).min(1) }).strict(),
|
|
@@ -97,6 +98,14 @@ const navigationSchema = z.union([
|
|
|
97
98
|
continue;
|
|
98
99
|
}
|
|
99
100
|
items.forEach((item, i) => {
|
|
101
|
+
const own2 = item;
|
|
102
|
+
if (own2?.searchPublic === true && own2.hidden !== true) {
|
|
103
|
+
ctx.addIssue({
|
|
104
|
+
code: "custom",
|
|
105
|
+
path: [...path, key, i, "searchPublic"],
|
|
106
|
+
message: '`searchPublic` only applies to a hidden item - add "hidden": true, or remove `searchPublic`.'
|
|
107
|
+
});
|
|
108
|
+
}
|
|
100
109
|
const inner = key === "languages" ? String(item?.language ?? "") : language;
|
|
101
110
|
const tab = item?.tab;
|
|
102
111
|
walk(item, [...path, key, i], inner, key === "tabs" ? [...tabs, String(tab ?? "")] : tabs);
|
package/src/lib/config-schema.ts
CHANGED
|
@@ -129,11 +129,17 @@ export type NavChildren =
|
|
|
129
129
|
| { products: ProductItem[] }
|
|
130
130
|
| { href: string };
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
export type
|
|
132
|
+
/** Any navigation item can be hidden: reachable only by its address - no
|
|
133
|
+
* switcher, tab or menu lists it, and its pages search only among
|
|
134
|
+
* themselves (plus the public ones, with `searchPublic`). See
|
|
135
|
+
* lib/hidden-sections.js. */
|
|
136
|
+
export type Hideable = { hidden?: boolean; searchPublic?: boolean };
|
|
137
|
+
|
|
138
|
+
export type TabItem = { tab: string; icon?: string } & Hideable & NavChildren;
|
|
139
|
+
export type VersionItem = { version: string; label?: string; tag?: string; default?: boolean } & Hideable & NavChildren;
|
|
140
|
+
export type LanguageItem = { language: string; label?: string } & Hideable & NavChildren;
|
|
141
|
+
export type DropdownItem = { dropdown: string; icon?: string } & Hideable & NavChildren;
|
|
142
|
+
export type ProductItem = { product: string; icon?: string; description?: string } & Hideable & NavChildren;
|
|
137
143
|
|
|
138
144
|
// `.strict()` on every variant is load-bearing, not decoration: Zod
|
|
139
145
|
// objects silently strip unrecognized keys by default, so without it a
|
|
@@ -142,7 +148,8 @@ export type ProductItem = { product: string; icon?: string; description?: string
|
|
|
142
148
|
// rejected - defeating the entire "exactly one child kind per level"
|
|
143
149
|
// rule this schema exists to enforce (mirroring Mintlify's own "a tab
|
|
144
150
|
// cannot contain both anchors and groups at the same level").
|
|
145
|
-
function withChildren<Base extends z.ZodRawShape>(
|
|
151
|
+
function withChildren<Base extends z.ZodRawShape>(ownFields: Base) {
|
|
152
|
+
const base = { ...ownFields, hidden: z.boolean().optional(), searchPublic: z.boolean().optional() };
|
|
146
153
|
return z.union([
|
|
147
154
|
z.object({ ...base, pages: z.array(navItemSchema) }).strict(),
|
|
148
155
|
z.object({ ...base, tabs: z.array(tabSchema).min(1) }).strict(),
|
|
@@ -247,6 +254,14 @@ const navigationSchema = z.union([
|
|
|
247
254
|
continue;
|
|
248
255
|
}
|
|
249
256
|
items.forEach((item, i) => {
|
|
257
|
+
const own = item as { hidden?: unknown; searchPublic?: unknown };
|
|
258
|
+
if (own?.searchPublic === true && own.hidden !== true) {
|
|
259
|
+
ctx.addIssue({
|
|
260
|
+
code: 'custom',
|
|
261
|
+
path: [...path, key, i, 'searchPublic'],
|
|
262
|
+
message: '`searchPublic` only applies to a hidden item - add "hidden": true, or remove `searchPublic`.',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
250
265
|
const inner = key === 'languages' ? String((item as { language?: unknown })?.language ?? '') : language;
|
|
251
266
|
const tab = (item as { tab?: unknown })?.tab;
|
|
252
267
|
walk(item, [...path, key, i], inner, key === 'tabs' ? [...tabs, String(tab ?? '')] : tabs);
|
package/src/lib/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { EXCLUDED_TOP_LEVEL_DIRS, navPageId } from './pages.js';
|
|
4
|
+
import { isHidden } from './hidden-sections.js';
|
|
4
5
|
import matter from 'gray-matter';
|
|
5
6
|
import { writedocsTempDir } from './writedocs-temp-dir.js';
|
|
6
7
|
import { readableTextOn } from './color.js';
|
|
@@ -787,6 +788,8 @@ export function resolveGlobalDropdowns(navigation: NavigationConfig): DropdownIt
|
|
|
787
788
|
* `{ tab: "Status", href: "..." }`). */
|
|
788
789
|
function firstSlugAmong(items: Container[]): string | null {
|
|
789
790
|
for (const item of items) {
|
|
791
|
+
// A hidden item is reached only by its address - never by default.
|
|
792
|
+
if (isHidden(item)) continue;
|
|
790
793
|
const slug = firstSlugOf(item);
|
|
791
794
|
if (slug !== null) return slug;
|
|
792
795
|
}
|
|
@@ -944,6 +947,11 @@ export interface SelectorOption {
|
|
|
944
947
|
export interface Selector {
|
|
945
948
|
kind: PathSegment['kind'];
|
|
946
949
|
options: SelectorOption[];
|
|
950
|
+
// The page sits inside a hidden item on this level: the level isn't
|
|
951
|
+
// shown (its switcher would list the other items). Kept in the list,
|
|
952
|
+
// not dropped, so the levels after it are placed by their real parent
|
|
953
|
+
// (lib/selector-placement.js).
|
|
954
|
+
hidden?: boolean;
|
|
947
955
|
}
|
|
948
956
|
|
|
949
957
|
/** An option's own `dropdowns` menu, if it has one (see SelectorOption.dropdown
|
|
@@ -958,12 +966,16 @@ function dropdownMenuOf(
|
|
|
958
966
|
activeSegment: PathSegment | undefined
|
|
959
967
|
): SelectorOption[] | undefined {
|
|
960
968
|
if (!('dropdowns' in node)) return undefined;
|
|
961
|
-
return node.dropdowns
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
969
|
+
return node.dropdowns
|
|
970
|
+
.map((d, i) => ({
|
|
971
|
+
label: d.dropdown,
|
|
972
|
+
icon: iconOf(d),
|
|
973
|
+
href: 'href' in d ? d.href : hrefForSlug(firstSlugOf(d) ?? 'index'),
|
|
974
|
+
active: activeSegment?.kind === 'dropdown' && activeSegment.index === i,
|
|
975
|
+
hidden: isHidden(d),
|
|
976
|
+
}))
|
|
977
|
+
.filter((d) => !d.hidden)
|
|
978
|
+
.map(({ hidden: _hidden, ...option }) => option);
|
|
967
979
|
}
|
|
968
980
|
|
|
969
981
|
/** `activePagePosition` is the reader's current page's own index within
|
|
@@ -986,7 +998,10 @@ export function buildSelectors(
|
|
|
986
998
|
const remainingPath = path.slice(segIndex + 1);
|
|
987
999
|
return {
|
|
988
1000
|
kind: segment.kind,
|
|
989
|
-
|
|
1001
|
+
hidden: isHidden(segment.items[segment.index]),
|
|
1002
|
+
options: (segment.items as NamedContainer[]).flatMap((item, i) => {
|
|
1003
|
+
// A hidden item is never offered - not even to its own pages.
|
|
1004
|
+
if (isHidden(item)) return [];
|
|
990
1005
|
const isActive = i === segment.index;
|
|
991
1006
|
const equivalentSlug = preservesPosition
|
|
992
1007
|
? equivalentPageIn(item as Container, remainingPath, activePagePosition)
|
package/src/lib/content-check.js
CHANGED
|
@@ -411,6 +411,20 @@ export async function checkContent(contentDir, configText) {
|
|
|
411
411
|
)
|
|
412
412
|
);
|
|
413
413
|
}
|
|
414
|
+
// Every top-level item hidden, and no home page: `/` never redirects into a
|
|
415
|
+
// hidden item (lib/hidden-sections.js), so the site's address has nothing.
|
|
416
|
+
const nav = config.navigation;
|
|
417
|
+
const topKey = nav && !Array.isArray(nav) ? ['tabs', 'versions', 'languages', 'dropdowns', 'products'].find((k) => Array.isArray(nav[k])) : null;
|
|
418
|
+
if (topKey && nav[topKey].length && nav[topKey].every((item) => item?.hidden === true || 'href' in (item ?? {})) && !ids.has('index')) {
|
|
419
|
+
warnings.push(
|
|
420
|
+
issue(
|
|
421
|
+
'writedocs.json',
|
|
422
|
+
locate(['navigation', topKey])?.line,
|
|
423
|
+
`Every item in navigation.${topKey} is hidden and there's no home page - the site's own address (/) shows "not found".`,
|
|
424
|
+
'Add an index.mdx at the project root for a home page, or leave one item not hidden.'
|
|
425
|
+
)
|
|
426
|
+
);
|
|
427
|
+
}
|
|
414
428
|
// A redirect is one exact path - a Mintlify-style pattern (`/old/:slug`,
|
|
415
429
|
// `/old/*`) becomes a literal page path, and the build fails on it.
|
|
416
430
|
(Array.isArray(config.redirects) ? config.redirects : []).forEach((r, i) => {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Folder addresses - `/docs/creator/` when pages live at `/docs/creator/...`
|
|
2
|
+
// but none at `/docs/creator/` itself - redirect to the first page under
|
|
3
|
+
// them, as Mintlify does, instead of showing "not found". The same idea as
|
|
4
|
+
// the automatic `/` redirect, one level down.
|
|
5
|
+
//
|
|
6
|
+
// "First" is the reader's order: [...slug].astro passes the pages in
|
|
7
|
+
// navigation order, the public ones before those under a hidden item
|
|
8
|
+
// (lib/hidden-sections.js) and pages outside the navigation last - so a
|
|
9
|
+
// folder with both public and hidden pages under it goes to a public one.
|
|
10
|
+
// A real page or a writedocs.json redirect at the address always wins.
|
|
11
|
+
//
|
|
12
|
+
// Temporary (302), like `/`: a browser doesn't keep it, so a page added at
|
|
13
|
+
// that address later is seen. Also used by `writedocs broken-links` (a link
|
|
14
|
+
// to a folder address leads somewhere) and the sitemap (which leaves them
|
|
15
|
+
// out, like every redirect).
|
|
16
|
+
//
|
|
17
|
+
// Slugs are page addresses without their slashes: "docs/creator/home".
|
|
18
|
+
|
|
19
|
+
/** First path segments writedocs itself serves - never a folder redirect. */
|
|
20
|
+
export const RESERVED_FOLDERS = new Set(['_astro', 'pagefind', 'pagefind-spaces', 'llms', 'mcp']);
|
|
21
|
+
|
|
22
|
+
/** Every folder above a page: "a/b/c" -> ["a", "a/b"]. */
|
|
23
|
+
export function foldersOf(slug) {
|
|
24
|
+
const parts = slug.split('/').filter(Boolean);
|
|
25
|
+
return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/'));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The folder redirects for a site: folder -> the page it goes to.
|
|
29
|
+
* `targets` - the pages, in the order to prefer them; `taken` - every
|
|
30
|
+
* address already served (pages, redirects, `url` pages). */
|
|
31
|
+
export function folderRedirects(targets, taken = new Set()) {
|
|
32
|
+
const redirects = new Map();
|
|
33
|
+
for (const slug of targets) {
|
|
34
|
+
if (slug === 'index') continue;
|
|
35
|
+
for (const folder of foldersOf(slug)) {
|
|
36
|
+
if (redirects.has(folder) || taken.has(folder) || RESERVED_FOLDERS.has(folder.split('/')[0])) continue;
|
|
37
|
+
redirects.set(folder, slug);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return redirects;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Just the folder addresses, for checks that don't need the targets. */
|
|
44
|
+
export function folderAddresses(pageSlugs) {
|
|
45
|
+
const pages = new Set(pageSlugs);
|
|
46
|
+
return new Set([...folderRedirects(pageSlugs, pages).keys()]);
|
|
47
|
+
}
|