@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.
Files changed (39) hide show
  1. package/astro.config.mjs +10 -4
  2. package/bin/writedocs.js +28 -7
  3. package/package.json +1 -1
  4. package/src/cli/build-auth.js +16 -9
  5. package/src/cli/build.js +3 -1
  6. package/src/cli/convert.js +6 -2
  7. package/src/cli/dev.js +3 -1
  8. package/src/cli/generate-api-pages.js +2 -2
  9. package/src/cli/init.js +3 -1
  10. package/src/cli/output.js +8 -1
  11. package/src/cli/preflight.js +63 -1
  12. package/src/cli/update.js +6 -1
  13. package/src/cli/write-redirects-file.js +2 -1
  14. package/src/components/ApiReferencePanel.astro +46 -12
  15. package/src/components/Steps.astro +2 -2
  16. package/src/layout/BaseLayout.astro +21 -6
  17. package/src/layout/styles/banner.css +1 -1
  18. package/src/lib/a11y-check.js +19 -42
  19. package/src/lib/agent-markdown.js +137 -0
  20. package/src/lib/asset-path.js +29 -0
  21. package/src/lib/color.js +49 -0
  22. package/src/lib/config-file.js +25 -0
  23. package/src/lib/config-schema.js +15 -12
  24. package/src/lib/config-schema.ts +20 -12
  25. package/src/lib/config.ts +18 -135
  26. package/src/lib/json-schema-descriptions.js +1 -1
  27. package/src/lib/llms-index.ts +88 -0
  28. package/src/lib/llms.js +200 -0
  29. package/src/lib/mintlify-convert.js +2 -1
  30. package/src/lib/pages.js +137 -11
  31. package/src/lib/styles-asset-integration.js +1 -18
  32. package/src/lib/writedocs-legacy-convert.js +2 -1
  33. package/src/pages/[...slug].astro +11 -1
  34. package/src/pages/[...slug].md.ts +15 -6
  35. package/src/pages/llms/[...path].md.ts +23 -0
  36. package/src/pages/llms-full.txt.ts +30 -9
  37. package/src/pages/llms.txt.ts +21 -125
  38. package/src/scripts/search.ts +39 -14
  39. package/writedocs.schema.json +2 -2
package/astro.config.mjs CHANGED
@@ -143,26 +143,33 @@ function walkMdFiles(baseDir) {
143
143
  function collectNoindexIds(rootContentDir) {
144
144
  const generatedDocsDir = path.join(writedocsTempDir(rootContentDir), 'generated-docs');
145
145
  const ids = new Set();
146
+ // Whether some page is served at "/" itself. When none is, "/" is the
147
+ // redirect-only route [...slug].astro synthesizes (noindex, pointing at
148
+ // the first page) - which doesn't belong in the sitemap either.
149
+ let rootIsPage = false;
146
150
 
147
151
  for (const relativeId of findAllPages(rootContentDir)) {
148
152
  const file = path.join(rootContentDir, relativeId);
149
153
  const { data } = matter(fs.readFileSync(file, 'utf-8'));
154
+ const pageId = normalizeEntryId(data?.slug ?? relativeId.replace(/\.mdx?$/i, '').replace(/\/index$/, ''));
155
+ if (pageId === 'index') rootIsPage = true;
150
156
  // Top-level `noindex` is Mintlify's spelling, and a Mintlify `hidden`
151
157
  // page is noindexed too - content.config.ts folds both into
152
158
  // `seo.noindex` the same way (an explicit value wins).
153
159
  // A frontmatter `url` page's route only redirects to that link, so it
154
160
  // doesn't belong in the sitemap either.
155
161
  if (!(data?.seo?.noindex ?? data?.noindex ?? data?.hidden === true) && !data?.url) continue;
156
- const fallbackId = relativeId.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
157
- ids.add(normalizeEntryId(data.slug ?? fallbackId));
162
+ ids.add(pageId);
158
163
  }
159
164
  // Generated OpenAPI stub pages always set an explicit `slug` (see
160
165
  // generate-api-pages.js) - there's no file-path-derived id to fall back
161
166
  // to the way there is for a hand-written page.
162
167
  for (const file of walkMdFiles(generatedDocsDir)) {
163
168
  const { data } = matter(fs.readFileSync(file, 'utf-8'));
169
+ if (data?.slug && normalizeEntryId(data.slug) === 'index') rootIsPage = true;
164
170
  if ((data?.seo?.noindex ?? data?.noindex ?? data?.hidden === true) && data.slug) ids.add(normalizeEntryId(data.slug));
165
171
  }
172
+ if (!rootIsPage) ids.add('index');
166
173
  return ids;
167
174
  }
168
175
 
@@ -513,8 +520,7 @@ export default defineConfig({
513
520
  // from such a file never finds a package.json named "writedocs",
514
521
  // so self-reference fails and Vite reports the bare specifier as
515
522
  // unresolvable ("Rolldown failed to resolve import
516
- // 'writedocs/components'"). See BUG.md for the full writeup this
517
- // fix comes from.
523
+ // 'writedocs/components'").
518
524
  // This alias bypasses self-reference resolution entirely for
519
525
  // that one specifier - Vite's alias matching is a plain string
520
526
  // match against the specifier itself, independent of which file
package/bin/writedocs.js CHANGED
@@ -10,6 +10,7 @@ import { runInit } from '../src/cli/init.js';
10
10
  import { requireBuildKey } from '../src/cli/build-auth.js';
11
11
  import { log, step, plural, color, CliExit, errorText, stopActiveStep } from '../src/cli/output.js';
12
12
  import { startUpdateCheck, showUpdateNotice } from '../src/cli/update-check.js';
13
+ import { readConfigText } from '../src/lib/config-file.js';
13
14
  // O MESMO modulo que o build usa (via loadDocsConfig, que reexporta daqui) e
14
15
  // que a plataforma importa por `@writedocs/generator/config-schema` - e o que
15
16
  // faz os tres reportarem os mesmos problemas com as mesmas palavras, em vez de
@@ -45,6 +46,9 @@ program
45
46
  .name('writedocs')
46
47
  .description('Static site generator for writedocs.json + MDX')
47
48
  .version(version)
49
+ // The built-in `help <command>` prints any command's usage, hidden ones
50
+ // included - replaced by the `help` command at the bottom of this file.
51
+ .helpCommand(false)
48
52
  // Every command checks for a newer writedocs (from a cache - see
49
53
  // src/cli/update-check.js); the notice prints after the command's output.
50
54
  .hook('preAction', (_program, command) => {
@@ -72,6 +76,9 @@ program
72
76
  // src/cli/build-auth.js for the second layer: even someone who knows the
73
77
  // command exists still can't run it without the right key.
74
78
  .command('build', { hidden: true })
79
+ // `build --help` prints the general help, which doesn't list it: hidden
80
+ // means not described anywhere (see the `help` command at the bottom too).
81
+ .configureHelp({ formatHelp: () => program.helpInformation() })
75
82
  .description('Build a static site into <dir>/dist')
76
83
  .argument('[dir]', 'content directory (contains writedocs.json and docs/)', '.')
77
84
  .option('-k, --key <key>', 'build authorization key (or set WRITEDOCS_API_KEY)')
@@ -79,8 +86,8 @@ program
79
86
  .action(async (dir, opts) => {
80
87
  const contentDir = path.resolve(process.cwd(), dir);
81
88
  // Project-scoped, not global: a .env sitting next to this project's own
82
- // writedocs.json (WRITEDOCS_KEY_SERVER_URL, WRITEDOCS_API_KEY) is picked
83
- // up automatically, so build doesn't need those exported by hand every
89
+ // writedocs.json (WRITEDOCS_API_KEY) is picked up automatically, so
90
+ // build doesn't need it exported by hand every
84
91
  // session. dotenv never overwrites a var already set in the real
85
92
  // environment - an explicit `export`/CI secret still wins over the file.
86
93
  dotenv.config({ path: path.join(contentDir, '.env'), quiet: true });
@@ -106,7 +113,7 @@ program
106
113
  throw new CliExit(1);
107
114
  }
108
115
 
109
- const rawText = fs.readFileSync(configPath, 'utf-8');
116
+ const rawText = readConfigText(contentDir);
110
117
  const result = validateDocsConfig(rawText);
111
118
  // Chave desconhecida na raiz e AVISO, nunca erro: a raiz nao e `.strict()`
112
119
  // de proposito (tornar strict quebraria configs existentes), entao o Zod
@@ -186,7 +193,7 @@ program
186
193
  const contentDir = path.resolve(process.cwd(), dir);
187
194
  const { preflightCheck } = await import('../src/cli/preflight.js');
188
195
  preflightCheck(contentDir);
189
- const configText = fs.readFileSync(path.join(contentDir, 'writedocs.json'), 'utf-8');
196
+ const configText = readConfigText(contentDir);
190
197
  const checking = step('Checking links');
191
198
  // Generated OpenAPI pages are link targets too - same step dev/build run.
192
199
  const { generateApiPages } = await import('../src/cli/generate-api-pages.js');
@@ -221,7 +228,7 @@ program
221
228
  const contentDir = path.resolve(process.cwd(), dir);
222
229
  const { preflightCheck } = await import('../src/cli/preflight.js');
223
230
  preflightCheck(contentDir);
224
- const configText = fs.readFileSync(path.join(contentDir, 'writedocs.json'), 'utf-8');
231
+ const configText = readConfigText(contentDir);
225
232
  const checking = step('Checking accessibility');
226
233
  const { checkAccessibility } = await import('../src/lib/a11y-check.js');
227
234
  const { formatContentIssues } = await import('../src/lib/content-check.js');
@@ -252,8 +259,10 @@ program
252
259
  .option('--force', 'overwrite an existing writedocs.json')
253
260
  .option('--dry-run', 'print the converted writedocs.json instead of writing it')
254
261
  .action(async (dir, options) => {
255
- const mintlify = options.mintlify || options.docsJson;
256
- const legacy = options.writedocs || options.configJson;
262
+ // Commander camel-cases only on hyphens: `--docs.json` is stored under
263
+ // the key "docs.json", not `docsJson`.
264
+ const mintlify = options.mintlify || options['docs.json'];
265
+ const legacy = options.writedocs || options['config.json'];
257
266
  if (mintlify === legacy) {
258
267
  log.error(
259
268
  mintlify
@@ -287,6 +296,18 @@ program
287
296
  await runInit({ targetDir: path.resolve(process.cwd(), dir) });
288
297
  });
289
298
 
299
+ // `writedocs help [command]` - commander's own, except that a hidden command
300
+ // (`build`) gets the general help, like a name that isn't a command at all.
301
+ const HIDDEN_COMMANDS = new Set(['build']);
302
+ program
303
+ .command('help')
304
+ .description('display help for command')
305
+ .argument('[command]')
306
+ .action((name) => {
307
+ const command = name && !HIDDEN_COMMANDS.has(name) ? program.commands.find((c) => c.name() === name) : undefined;
308
+ (command ?? program).help();
309
+ });
310
+
290
311
  program
291
312
  .parseAsync(process.argv)
292
313
  .then(showUpdateNotice)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Static site generator for docs — a writedocs.json + MDX folder in, a static site out.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * `writedocs build` produces the artifact that actually gets deployed, so
3
3
  * unlike `dev`/`init` it isn't freely runnable by anyone who installs the
4
- * package - it's gated behind a key that a separate service (`key-server/`
5
- * in this repo) actually decides the validity of.
4
+ * package - it's gated behind a key that a separate service
5
+ * (writedocs-key-server, a sibling repo) actually decides the validity of.
6
6
  *
7
7
  * This deliberately isn't a local check. An earlier version compared the
8
8
  * supplied key against an env var set on the same machine
@@ -12,19 +12,26 @@
12
12
  * actually holds the set of issued keys means a key's validity is decided
13
13
  * by whoever runs that server, not by whoever is running `build`.
14
14
  *
15
- * See docs/dev/docs/deploy.mdx for how to deploy key-server/ and issue
16
- * keys, and key-server/README.md for the service's own endpoints.
15
+ * See docs/dev/docs/deploy.mdx for how to deploy writedocs-key-server and
16
+ * issue keys, and that repo's own README for the service's endpoints.
17
17
  */
18
18
  import { log } from './output.js';
19
19
 
20
20
  export const EX_TEMPFAIL = 75;
21
21
 
22
+ /** The authorization server every `build` asks. Fixed in the code, with no
23
+ * environment override: when the URL came from WRITEDOCS_KEY_SERVER_URL,
24
+ * anyone could point it at a 3-line server that answers
25
+ * `{ "valid": true }` and build without a key. Tests and CI use a real
26
+ * key instead (the WRITEDOCS_API_KEY secret - see
27
+ * scripts/smoke-installed-cli.sh). Honest limit: the source ships with the
28
+ * package, so someone willing to edit it can still remove this check - it
29
+ * raises the bar, it isn't a lock. Real protection is what the platform
30
+ * does with the output. */
31
+ export const KEY_SERVER_URL = 'https://proxy.writechoice.io:8787';
32
+
22
33
  export async function requireBuildKey(providedKey) {
23
- const serverUrl = process.env.WRITEDOCS_KEY_SERVER_URL;
24
- if (!serverUrl) {
25
- log.error('build is not available.');
26
- process.exit(1);
27
- }
34
+ const serverUrl = KEY_SERVER_URL;
28
35
  if (!providedKey) {
29
36
  log.error('build requires a valid --key (or WRITEDOCS_API_KEY).');
30
37
  process.exit(1);
package/src/cli/build.js CHANGED
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { runAstro } from './run-astro.js';
4
4
  import { runPagefind } from './run-pagefind.js';
5
- import { preflightCheck } from './preflight.js';
5
+ import { preflightCheck, sameDriveCheck, writableInstallCheck } from './preflight.js';
6
6
  import { generateApiPages } from './generate-api-pages.js';
7
7
  import { writeRedirectsFile } from './write-redirects-file.js';
8
8
  import { writedocsBuildStagingDir } from '../lib/writedocs-temp-dir.js';
@@ -13,6 +13,7 @@ import { reportApiPages } from './api-pages-output.js';
13
13
  export async function runBuild({ contentDir, packageRoot, verbose = false }) {
14
14
  const started = Date.now();
15
15
  preflightCheck(contentDir);
16
+ writableInstallCheck(packageRoot);
16
17
 
17
18
  // Collected while building, printed together at the end - a warning in
18
19
  // the middle of a spinner is easy to miss.
@@ -27,6 +28,7 @@ export async function runBuild({ contentDir, packageRoot, verbose = false }) {
27
28
  };
28
29
 
29
30
  const api = await generateApiPages({ contentDir });
31
+ sameDriveCheck(contentDir, packageRoot, { generatedPages: api.groups.length > 0 });
30
32
  reportApiPages(api);
31
33
  api.warnings.forEach((message) => addWarning(null, message));
32
34
 
@@ -10,7 +10,7 @@ import { loadMintlifyConfig, convertMintlifyConfig, formatNotes } from '../lib/m
10
10
  import { loadLegacyConfig, convertLegacyConfig } from '../lib/writedocs-legacy-convert.js';
11
11
  import { validateDocsConfig, formatValidationIssuesDetailed } from '../lib/config-schema.js';
12
12
  import { checkContent, formatContentIssues } from '../lib/content-check.js';
13
- import { log, step, plural, color, displayPath, CliExit } from './output.js';
13
+ import { log, step, plural, color, displayPath, CliExit, logToStderr } from './output.js';
14
14
 
15
15
  const SOURCES = {
16
16
  mintlify: {
@@ -39,6 +39,10 @@ const SOURCES = {
39
39
  };
40
40
 
41
41
  export async function runConvert({ source = 'mintlify', contentDir, force = false, dryRun = false }) {
42
+ // --dry-run's stdout is the converted writedocs.json and nothing else, so
43
+ // `writedocs convert --dry-run > writedocs.json` works; the notes and the
44
+ // page check go to stderr.
45
+ if (dryRun) logToStderr();
42
46
  const from = SOURCES[source];
43
47
  const inputPath = path.join(contentDir, from.file);
44
48
  if (!fs.existsSync(inputPath)) {
@@ -75,7 +79,7 @@ export async function runConvert({ source = 'mintlify', contentDir, force = fals
75
79
  }
76
80
 
77
81
  if (dryRun) {
78
- log.line(text);
82
+ process.stdout.write(text);
79
83
  } else {
80
84
  fs.writeFileSync(outPath, text);
81
85
  log.success(`Converted ${from.file} to ${color.bold(displayPath(outPath))}`);
package/src/cli/dev.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { runAstro } from './run-astro.js';
2
- import { preflightCheck } from './preflight.js';
2
+ import { preflightCheck, sameDriveCheck, writableInstallCheck } from './preflight.js';
3
3
  import { generateApiPages } from './generate-api-pages.js';
4
4
  import { log, step, duration, formatProblems, color, CliExit } from './output.js';
5
5
  import { describeError, requestLog, authorWarning, verboseLine, stripAnsi } from './astro-output.js';
@@ -15,6 +15,7 @@ const REPEAT_WINDOW_MS = 2000;
15
15
  export async function runDev({ contentDir, packageRoot, port, verbose = false }) {
16
16
  const started = Date.now();
17
17
  preflightCheck(contentDir);
18
+ writableInstallCheck(packageRoot);
18
19
  const other = runningPreview(packageRoot);
19
20
  if (other) {
20
21
  log.error(`Another writedocs preview is already running${other.url ? ` at ${other.url}` : ''}.`);
@@ -29,6 +30,7 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false })
29
30
  process.on('exit', () => removeLock(packageRoot));
30
31
 
31
32
  const api = await generateApiPages({ contentDir });
33
+ sameDriveCheck(contentDir, packageRoot, { generatedPages: api.groups.length > 0 });
32
34
  reportApiPages(api);
33
35
  api.warnings.forEach((message) => log.warn(message));
34
36
 
@@ -5,6 +5,7 @@ import matter from 'gray-matter';
5
5
  import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
6
6
  import { parseOpenApiRef, openApiOperationKey, specDirName } from '../lib/openapi-ref.js';
7
7
  import { findAllPages } from '../lib/pages.js';
8
+ import { readConfigText } from '../lib/config-file.js';
8
9
  import { HTTP_METHODS, collectOpenApiGroups } from '../lib/openapi-spec.js';
9
10
 
10
11
 
@@ -298,13 +299,12 @@ async function generateApiPagesForGroup({ contentDir, generatedDocsDir, group, o
298
299
  */
299
300
  export async function generateApiPages({ contentDir }) {
300
301
  const summary = { groups: [], pageSpecs: [], warnings: [] };
301
- const writedocsJsonPath = path.join(contentDir, 'writedocs.json');
302
302
  const generatedDocsDir = path.join(writedocsTempDir(contentDir), 'generated-docs');
303
303
  const openapiOutDir = path.join(writedocsTempDir(contentDir), 'openapi');
304
304
 
305
305
  let config;
306
306
  try {
307
- config = JSON.parse(fs.readFileSync(writedocsJsonPath, 'utf-8'));
307
+ config = JSON.parse(readConfigText(contentDir));
308
308
  } catch {
309
309
  return summary; // preflightCheck() (run first, see dev.js/build.js) already reports this
310
310
  }
package/src/cli/init.js CHANGED
@@ -6,7 +6,9 @@ const WRITEDOCS_JSON = {
6
6
  name: 'My Docs',
7
7
  description: 'Documentation site built with Writedocs',
8
8
  styles: {
9
- colors: { primary: '#6366f1' },
9
+ // Readable as link text on both default backgrounds - a new project
10
+ // passes `writedocs a11y` (scripts/init.test.js).
11
+ colors: { primary: '#4f46e5', dark: { primary: '#a5b4fc' } },
10
12
  },
11
13
  navigation: [
12
14
  { group: 'Getting Started', pages: ['index', 'docs/getting-started'] },
package/src/cli/output.js CHANGED
@@ -9,13 +9,20 @@
9
9
  // it finishes. NO_COLOR turns colors off; FORCE_COLOR turns them on.
10
10
  import path from 'node:path';
11
11
 
12
- const stream = process.stdout;
12
+ let stream = process.stdout;
13
13
  // Output piped into something that stopped reading (`writedocs build | head`):
14
14
  // nothing more can be shown, so stop instead of crashing on EPIPE.
15
15
  stream.on('error', (err) => {
16
16
  if (err.code === 'EPIPE') process.exit(0);
17
17
  throw err;
18
18
  });
19
+
20
+ /** Sends everything this module prints to stderr from here on - for a
21
+ * command whose stdout is data: `convert --dry-run` prints the converted
22
+ * writedocs.json there, so `> writedocs.json` gets exactly that. */
23
+ export function logToStderr() {
24
+ stream = process.stderr;
25
+ }
19
26
  const interactive = Boolean(stream.isTTY) && process.env.TERM !== 'dumb' && !process.env.CI;
20
27
  const useColor =
21
28
  process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '0'
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { log, color, CliExit } from './output.js';
5
+ import { readConfigText } from '../lib/config-file.js';
4
6
 
5
7
  /**
6
8
  * Fast, dependency-light sanity check that runs before handing off to
@@ -28,7 +30,7 @@ export function preflightCheck(contentDir) {
28
30
  throw new CliExit(1);
29
31
  }
30
32
  try {
31
- JSON.parse(fs.readFileSync(configPath, 'utf-8'));
33
+ JSON.parse(readConfigText(contentDir));
32
34
  } catch (err) {
33
35
  log.error(`writedocs.json is not valid JSON: ${err.message}`);
34
36
  log.detail(color.dim('Run "writedocs validate" to see where.'));
@@ -41,3 +43,63 @@ export function preflightCheck(contentDir) {
41
43
  // later, with a more specific error naming the missing page - see
42
44
  // getStaticPaths() in src/pages/[...slug].astro.
43
45
  }
46
+
47
+ /** The drive (`C:`) or UNC share a Windows path lives on, lowercased. */
48
+ function volumeOf(p) {
49
+ return path.win32.parse(path.win32.resolve(p)).root.replace(/[\\/]+$/, '').toLowerCase();
50
+ }
51
+
52
+ /**
53
+ * Windows only, before `dev`/`build` start Astro: the project has to be on
54
+ * the same drive as writedocs itself. Astro is run with writedocs' own
55
+ * folder as its root (see run-astro.js) and records every page's path
56
+ * relative to it - across drives there is no relative path, the
57
+ * `D:/...` it gets instead reads as a URL scheme, and the build dies with
58
+ * "The URL must be of scheme file". The same holds for the temp folder
59
+ * when it holds generated OpenAPI pages (`generatedPages` - writedocsTempDir()
60
+ * is Astro content then; the cache and inline React components there build
61
+ * fine across drives). This stops with what to do instead of that message.
62
+ * `platform` and `tempDir` are parameters for tests.
63
+ */
64
+ export function sameDriveCheck(contentDir, packageRoot, { generatedPages = false, platform = process.platform, tempDir = os.tmpdir() } = {}) {
65
+ if (platform !== 'win32') return;
66
+ const install = volumeOf(packageRoot);
67
+ const project = volumeOf(contentDir);
68
+ if (project !== install) {
69
+ log.error(`This project is on ${project.toUpperCase()}, and writedocs is installed on ${install.toUpperCase()} - Astro can't build across drives on Windows.`);
70
+ log.detail(color.dim('Install writedocs in the project instead (npm install --save-dev @writedocs/generator, then npx writedocs ...),'));
71
+ log.detail(color.dim(`or move the project to drive ${install.toUpperCase()}.`));
72
+ throw new CliExit(1);
73
+ }
74
+ const temp = volumeOf(tempDir);
75
+ if (generatedPages && temp !== install) {
76
+ log.error(`The temp folder (${tempDir}) is on ${temp.toUpperCase()}, and writedocs is installed on ${install.toUpperCase()} - Astro can't build the generated API pages across drives on Windows.`);
77
+ log.detail(color.dim(`Point TEMP and TMP at a folder on drive ${install.toUpperCase()}, or install writedocs on ${temp.toUpperCase()}.`));
78
+ throw new CliExit(1);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Before `dev`/`build` start Astro: writedocs has to be able to write inside
84
+ * its own folder - Astro runs with it as its root (run-astro.js), and keeps
85
+ * `.astro/`, Vite's `node_modules/.vite/` and the build's staging folder
86
+ * (writedocsBuildStagingDir()) there. A global install made with sudo is
87
+ * owned by root, and both commands then die on a bare EACCES/EPERM from
88
+ * deep inside Astro or Vite. A real write, not fs.accessSync(): on Windows
89
+ * that only looks at the read-only attribute, not at permissions.
90
+ */
91
+ export function writableInstallCheck(packageRoot) {
92
+ const probe = path.join(packageRoot, '.astro', `.write-check-${process.pid}`);
93
+ try {
94
+ fs.mkdirSync(path.dirname(probe), { recursive: true });
95
+ fs.writeFileSync(probe, '');
96
+ fs.rmSync(probe, { force: true });
97
+ } catch (err) {
98
+ if (err.code !== 'EACCES' && err.code !== 'EPERM') throw err;
99
+ log.error(`writedocs can't write to its own folder (${packageRoot}), and dev and build need to.`);
100
+ log.detail(color.dim('This happens with a global install made with sudo. Install writedocs in the project instead'));
101
+ log.detail(color.dim('(npm install --save-dev @writedocs/generator, then npx writedocs ...), or set npm up to install'));
102
+ log.detail(color.dim('globally without sudo: https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally'));
103
+ throw new CliExit(1);
104
+ }
105
+ }
package/src/cli/update.js CHANGED
@@ -34,7 +34,12 @@ export async function installation(packageRoot, name) {
34
34
  if (/\/_npx\//.test(posix)) return { kind: 'npx' };
35
35
  if (/\/pnpm\/global\//.test(posix)) return { kind: 'global', command: 'pnpm', args: ['add', '-g', `${name}@latest`] };
36
36
  if (/\/yarn\/global\//.test(posix)) return { kind: 'global', command: 'yarn', args: ['global', 'add', `${name}@latest`] };
37
- const at = posix.lastIndexOf(`/node_modules/${name}`);
37
+ // pnpm's default layout keeps the real package inside its store,
38
+ // <project>/node_modules/.pnpm/<name>@<version>/node_modules/<name>, and
39
+ // Node resolves the symlink to that path - so the project is what comes
40
+ // before .pnpm, not before the last node_modules/<name>.
41
+ const pnpmStore = posix.indexOf('/node_modules/.pnpm/');
42
+ const at = pnpmStore !== -1 ? pnpmStore : posix.lastIndexOf(`/node_modules/${name}`);
38
43
  if (at === -1) return { kind: 'unknown' };
39
44
  const container = posix.slice(0, at);
40
45
  const npmRoot = await run('npm', ['root', '-g']);
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { readConfigText } from '../lib/config-file.js';
3
4
 
4
5
  // Astro's own `output: 'static'` redirects (both writedocs.json's `redirects`
5
6
  // feature and the automatic "/" -> first-nav-page redirect in
@@ -31,7 +32,7 @@ import path from 'node:path';
31
32
  // the one `redirects` array back out, nothing that needs zod's defaults
32
33
  // or transforms.
33
34
  export function writeRedirectsFile(distDir, contentDir) {
34
- const writedocsJson = JSON.parse(fs.readFileSync(path.join(contentDir, 'writedocs.json'), 'utf8'));
35
+ const writedocsJson = JSON.parse(readConfigText(contentDir));
35
36
  const redirects = writedocsJson.redirects ?? [];
36
37
  const lines = [];
37
38
 
@@ -658,7 +658,7 @@ const securityJson = JSON.stringify(op?.security ?? []);
658
658
  border-radius: 0.5rem;
659
659
  border: none;
660
660
  background: var(--wd-primary);
661
- color: #fff;
661
+ color: var(--wd-on-primary);
662
662
  font-size: 0.75rem;
663
663
  font-weight: 600;
664
664
  cursor: pointer;
@@ -931,7 +931,7 @@ const securityJson = JSON.stringify(op?.security ?? []);
931
931
  border-radius: 0.4rem;
932
932
  border: none;
933
933
  background: var(--wd-primary);
934
- color: #fff;
934
+ color: var(--wd-on-primary);
935
935
  font-size: 0.85rem;
936
936
  font-weight: 600;
937
937
  cursor: pointer;
@@ -1123,6 +1123,8 @@ const securityJson = JSON.stringify(op?.security ?? []);
1123
1123
  // after the base (protocol required), preserving method/headers/body,
1124
1124
  // and returns CORS headers so the browser fetch() below succeeds even
1125
1125
  // when the target API itself doesn't send Access-Control-Allow-Origin.
1126
+ // Only a fallback: a request goes straight to the API first, and through
1127
+ // here only when the browser blocks that (see the send handler below).
1126
1128
  // Toggle via writedocs.json's `api.proxy` (see data-proxy on the tryit
1127
1129
  // section below); the displayed curl/fetch/python snippets deliberately
1128
1130
  // keep showing the real, non-proxied URL - only this in-browser request
@@ -1322,17 +1324,26 @@ const securityJson = JSON.stringify(op?.security ?? []);
1322
1324
  const responseBodyEl = section.querySelector<HTMLElement>('[data-role="response-body"] code');
1323
1325
  const modalLangPanels = Array.from(section.querySelectorAll<HTMLElement>('[data-modal-lang-panel]'));
1324
1326
 
1327
+ // A reader's credentials last for the tab, not forever: sessionStorage
1328
+ // carries them across the pages of one visit and forgets them when the
1329
+ // tab closes. Older versions kept them in localStorage indefinitely -
1330
+ // a key found there is moved over once and deleted.
1325
1331
  authInputs.forEach((input) => {
1326
- const name = input.dataset.authName ?? '';
1332
+ const storageKey = `wd-api-auth:${input.dataset.authName ?? ''}`;
1327
1333
  try {
1328
- const saved = localStorage.getItem(`wd-api-auth:${name}`);
1334
+ const legacy = localStorage.getItem(storageKey);
1335
+ if (legacy !== null) {
1336
+ if (sessionStorage.getItem(storageKey) === null) sessionStorage.setItem(storageKey, legacy);
1337
+ localStorage.removeItem(storageKey);
1338
+ }
1339
+ const saved = sessionStorage.getItem(storageKey);
1329
1340
  if (saved) input.value = saved;
1330
1341
  } catch {
1331
- // localStorage unavailable - auth just won't persist across reloads
1342
+ // storage unavailable - auth just won't carry across pages
1332
1343
  }
1333
1344
  input.addEventListener('input', () => {
1334
1345
  try {
1335
- localStorage.setItem(`wd-api-auth:${name}`, input.value);
1346
+ sessionStorage.setItem(storageKey, input.value);
1336
1347
  } catch {
1337
1348
  // ignore
1338
1349
  }
@@ -1714,9 +1725,13 @@ const securityJson = JSON.stringify(op?.security ?? []);
1714
1725
  }
1715
1726
 
1716
1727
  const targetUrl = baseUrl + requestPath + (query.toString() ? `?${query.toString()}` : '');
1717
- // Route through writedocs' CORS proxy unless disabled via writedocs.json's
1718
- // `api.proxy: false` - see PROXY_BASE_URL above for the contract.
1719
- const url = proxyEnabled ? `${PROXY_BASE_URL}${targetUrl}` : targetUrl;
1728
+ // A "simple" request (CORS spec: GET/HEAD/POST, no JSON body, no
1729
+ // auth or custom headers) skips the preflight, so the server may
1730
+ // already have run it when the browser blocks the response.
1731
+ // Retrying one that changes something (a POST) through the proxy
1732
+ // could run it twice - so that one case isn't retried.
1733
+ const isSimple = ['GET', 'HEAD', 'POST'].includes(method) && Object.keys(headers).length === 0;
1734
+ const mayHaveRun = isSimple && method === 'POST';
1720
1735
 
1721
1736
  if (sendBtn) {
1722
1737
  sendBtn.disabled = true;
@@ -1727,8 +1742,23 @@ const securityJson = JSON.stringify(op?.security ?? []);
1727
1742
  responseBodyEl.textContent = '';
1728
1743
 
1729
1744
  const startedAt = performance.now();
1745
+ let viaProxy = false;
1730
1746
  try {
1731
- const res = await fetch(url, { method, headers, body });
1747
+ // Straight to the API first, so a reader's key and body only reach
1748
+ // the API itself. fetch() rejects when the browser blocks the
1749
+ // response (CORS) or can't reach the host - only then, and only
1750
+ // when writedocs.json's `api.proxy` allows it, the same request is
1751
+ // retried through writedocs' proxy (PROXY_BASE_URL above), and the
1752
+ // status line says so.
1753
+ let res: Response;
1754
+ try {
1755
+ res = await fetch(targetUrl, { method, headers, body });
1756
+ } catch (directErr) {
1757
+ if (!proxyEnabled || mayHaveRun) throw directErr;
1758
+ viaProxy = true;
1759
+ responseStatusEl.textContent = 'Blocked by the browser (CORS) - retrying through the writedocs proxy...';
1760
+ res = await fetch(`${PROXY_BASE_URL}${targetUrl}`, { method, headers, body });
1761
+ }
1732
1762
  const elapsed = Math.round(performance.now() - startedAt);
1733
1763
  const text = await res.text();
1734
1764
  let pretty = text;
@@ -1737,11 +1767,15 @@ const securityJson = JSON.stringify(op?.security ?? []);
1737
1767
  } catch {
1738
1768
  // not JSON - show raw text as-is
1739
1769
  }
1740
- responseStatusEl.textContent = `${res.status} ${res.statusText} - ${elapsed}ms`;
1770
+ responseStatusEl.textContent = `${res.status} ${res.statusText} - ${elapsed}ms${viaProxy ? ' - sent through the writedocs proxy (the API blocks direct browser requests)' : ''}`;
1741
1771
  responseStatusEl.className = `wd-api-response-status ${res.ok ? 'wd-api-status-ok' : 'wd-api-status-error'}`;
1742
1772
  responseBodyEl.textContent = pretty;
1743
1773
  } catch (err) {
1744
- responseStatusEl.textContent = 'Request failed - likely blocked by CORS, or the base URL is unreachable from your browser.';
1774
+ responseStatusEl.textContent = viaProxy
1775
+ ? 'Request failed through the writedocs proxy too - the base URL may be unreachable from the internet.'
1776
+ : mayHaveRun && proxyEnabled
1777
+ ? 'Blocked by the browser (CORS). Not retried through the proxy: this POST may already have reached the API.'
1778
+ : 'Request failed - likely blocked by CORS, or the base URL is unreachable from your browser.';
1745
1779
  responseStatusEl.className = 'wd-api-response-status wd-api-status-error';
1746
1780
  responseBodyEl.textContent = err instanceof Error ? err.message : String(err);
1747
1781
  } finally {
@@ -27,7 +27,7 @@ import { extraClasses } from './class-names';
27
27
  height: 1.8rem;
28
28
  padding: 0.45rem;
29
29
  box-sizing: border-box;
30
- color: white;
30
+ color: var(--wd-on-primary);
31
31
  z-index: 1;
32
32
  }
33
33
  span.wd-step-icon {
@@ -47,7 +47,7 @@ import { extraClasses } from './class-names';
47
47
  height: 1.8rem;
48
48
  border-radius: 50%;
49
49
  background: var(--wd-primary);
50
- color: white;
50
+ color: var(--wd-on-primary);
51
51
  font-size: 0.85rem;
52
52
  font-weight: 600;
53
53
  display: flex;