@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 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
@@ -9,6 +9,8 @@ import { runBuild } from '../src/cli/build.js';
9
9
  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
+ import { startUpdateCheck, showUpdateNotice } from '../src/cli/update-check.js';
13
+ import { readConfigText } from '../src/lib/config-file.js';
12
14
  // O MESMO modulo que o build usa (via loadDocsConfig, que reexporta daqui) e
13
15
  // que a plataforma importa por `@writedocs/generator/config-schema` - e o que
14
16
  // faz os tres reportarem os mesmos problemas com as mesmas palavras, em vez de
@@ -37,13 +39,21 @@ const packageRoot = path.resolve(__dirname, '..');
37
39
  // package.json). `writedocs --version` should always reflect what actually
38
40
  // got published, not whatever this string happened to say at the time this
39
41
  // line was last hand-edited.
40
- const { version } = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
42
+ const { name: packageName, version } = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
41
43
 
42
44
  const program = new Command();
43
45
  program
44
46
  .name('writedocs')
45
47
  .description('Static site generator for writedocs.json + MDX')
46
- .version(version);
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)
52
+ // Every command checks for a newer writedocs (from a cache - see
53
+ // src/cli/update-check.js); the notice prints after the command's output.
54
+ .hook('preAction', (_program, command) => {
55
+ startUpdateCheck({ command: command.name(), name: packageName, version, packageRoot });
56
+ });
47
57
 
48
58
  program
49
59
  .command('dev')
@@ -66,6 +76,9 @@ program
66
76
  // src/cli/build-auth.js for the second layer: even someone who knows the
67
77
  // command exists still can't run it without the right key.
68
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() })
69
82
  .description('Build a static site into <dir>/dist')
70
83
  .argument('[dir]', 'content directory (contains writedocs.json and docs/)', '.')
71
84
  .option('-k, --key <key>', 'build authorization key (or set WRITEDOCS_API_KEY)')
@@ -73,8 +86,8 @@ program
73
86
  .action(async (dir, opts) => {
74
87
  const contentDir = path.resolve(process.cwd(), dir);
75
88
  // Project-scoped, not global: a .env sitting next to this project's own
76
- // writedocs.json (WRITEDOCS_KEY_SERVER_URL, WRITEDOCS_API_KEY) is picked
77
- // 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
78
91
  // session. dotenv never overwrites a var already set in the real
79
92
  // environment - an explicit `export`/CI secret still wins over the file.
80
93
  dotenv.config({ path: path.join(contentDir, '.env'), quiet: true });
@@ -100,7 +113,7 @@ program
100
113
  throw new CliExit(1);
101
114
  }
102
115
 
103
- const rawText = fs.readFileSync(configPath, 'utf-8');
116
+ const rawText = readConfigText(contentDir);
104
117
  const result = validateDocsConfig(rawText);
105
118
  // Chave desconhecida na raiz e AVISO, nunca erro: a raiz nao e `.strict()`
106
119
  // de proposito (tornar strict quebraria configs existentes), entao o Zod
@@ -180,7 +193,7 @@ program
180
193
  const contentDir = path.resolve(process.cwd(), dir);
181
194
  const { preflightCheck } = await import('../src/cli/preflight.js');
182
195
  preflightCheck(contentDir);
183
- const configText = fs.readFileSync(path.join(contentDir, 'writedocs.json'), 'utf-8');
196
+ const configText = readConfigText(contentDir);
184
197
  const checking = step('Checking links');
185
198
  // Generated OpenAPI pages are link targets too - same step dev/build run.
186
199
  const { generateApiPages } = await import('../src/cli/generate-api-pages.js');
@@ -215,7 +228,7 @@ program
215
228
  const contentDir = path.resolve(process.cwd(), dir);
216
229
  const { preflightCheck } = await import('../src/cli/preflight.js');
217
230
  preflightCheck(contentDir);
218
- const configText = fs.readFileSync(path.join(contentDir, 'writedocs.json'), 'utf-8');
231
+ const configText = readConfigText(contentDir);
219
232
  const checking = step('Checking accessibility');
220
233
  const { checkAccessibility } = await import('../src/lib/a11y-check.js');
221
234
  const { formatContentIssues } = await import('../src/lib/content-check.js');
@@ -246,8 +259,10 @@ program
246
259
  .option('--force', 'overwrite an existing writedocs.json')
247
260
  .option('--dry-run', 'print the converted writedocs.json instead of writing it')
248
261
  .action(async (dir, options) => {
249
- const mintlify = options.mintlify || options.docsJson;
250
- 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'];
251
266
  if (mintlify === legacy) {
252
267
  log.error(
253
268
  mintlify
@@ -265,6 +280,14 @@ program
265
280
  });
266
281
  });
267
282
 
283
+ program
284
+ .command('update')
285
+ .description('Update writedocs to the latest version')
286
+ .action(async () => {
287
+ const { runUpdate } = await import('../src/cli/update.js');
288
+ await runUpdate({ name: packageName, version, packageRoot });
289
+ });
290
+
268
291
  program
269
292
  .command('init')
270
293
  .description('Scaffold a writedocs.json and starter docs/ folder')
@@ -273,10 +296,25 @@ program
273
296
  await runInit({ targetDir: path.resolve(process.cwd(), dir) });
274
297
  });
275
298
 
276
- program.parseAsync(process.argv).catch((err) => {
277
- // A command that already printed its own error throws CliExit.
278
- stopActiveStep();
279
- if (err instanceof CliExit) process.exit(err.code);
280
- log.error(errorText(err));
281
- process.exit(1);
282
- });
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
+
311
+ program
312
+ .parseAsync(process.argv)
313
+ .then(showUpdateNotice)
314
+ .catch((err) => {
315
+ stopActiveStep();
316
+ // A command that already printed its own error throws CliExit.
317
+ if (!(err instanceof CliExit)) log.error(errorText(err));
318
+ showUpdateNotice();
319
+ process.exit(err instanceof CliExit ? err.code : 1);
320
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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,10 +1,11 @@
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';
6
6
  import { reportApiPages } from './api-pages-output.js';
7
7
  import { runningPreview, writeLock, removeLock } from './dev-lock.js';
8
+ import { showUpdateNotice } from './update-check.js';
8
9
 
9
10
  // The same problem tends to arrive more than once in a row - Vite and
10
11
  // Astro each log a failed page, and a page compiles for more than one
@@ -14,6 +15,7 @@ const REPEAT_WINDOW_MS = 2000;
14
15
  export async function runDev({ contentDir, packageRoot, port, verbose = false }) {
15
16
  const started = Date.now();
16
17
  preflightCheck(contentDir);
18
+ writableInstallCheck(packageRoot);
17
19
  const other = runningPreview(packageRoot);
18
20
  if (other) {
19
21
  log.error(`Another writedocs preview is already running${other.url ? ` at ${other.url}` : ''}.`);
@@ -28,6 +30,7 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false })
28
30
  process.on('exit', () => removeLock(packageRoot));
29
31
 
30
32
  const api = await generateApiPages({ contentDir });
33
+ sameDriveCheck(contentDir, packageRoot, { generatedPages: api.groups.length > 0 });
31
34
  reportApiPages(api);
32
35
  api.warnings.forEach((message) => log.warn(message));
33
36
 
@@ -96,6 +99,9 @@ export async function runDev({ contentDir, packageRoot, port, verbose = false })
96
99
  log.line();
97
100
  log.line(color.dim(' Edit any page and the preview updates. Press Ctrl+C to stop.'));
98
101
  log.line();
102
+ // `dev` runs until Ctrl+C - the notice goes under the ready screen,
103
+ // not after the command like everywhere else.
104
+ showUpdateNotice();
99
105
  return;
100
106
  }
101
107
  case 'fatal':
@@ -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
+ }
@@ -0,0 +1,18 @@
1
+ // Detached background process started by update-check.js: asks the npm
2
+ // registry for the latest version of package `process.argv[2]` and caches
3
+ // the answer for the next writedocs run. Prints nothing. A failure (offline,
4
+ // registry down) keeps the last known version but still records the
5
+ // attempt, so it's retried the next day, not on every command.
6
+ import { fetchLatestVersion, readCache, writeCache } from './update-check.js';
7
+
8
+ const name = process.argv[2];
9
+ let latest = null;
10
+ try {
11
+ latest = await fetchLatestVersion(name);
12
+ } catch {
13
+ const previous = readCache();
14
+ latest = previous?.name === name ? previous.latest : null;
15
+ }
16
+ try {
17
+ writeCache({ name, latest, checkedAt: Date.now() });
18
+ } catch {}
@@ -0,0 +1,104 @@
1
+ // "A newer writedocs is available" - shown after any command's output.
2
+ //
3
+ // Never slows a command down: the notice comes from a cached answer, and
4
+ // when that's more than a day old, a detached background process
5
+ // (update-check-refresh.js) asks the npm registry again and rewrites the
6
+ // cache for the next run - the same approach as npm's own update notifier.
7
+ //
8
+ // Not shown for `build` (the WriteDocs platform runs it, not a person) or
9
+ // `update` itself, in CI, when output isn't a terminal, when writedocs runs
10
+ // from a source checkout (updated with git, not npm), or with
11
+ // WRITEDOCS_NO_UPDATE_CHECK set.
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { spawn } from 'node:child_process';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { log, color } from './output.js';
18
+
19
+ const DAY = 24 * 60 * 60 * 1000;
20
+ const SILENT_COMMANDS = new Set(['build', 'update']);
21
+
22
+ export function cacheFile() {
23
+ const base =
24
+ process.env.XDG_CACHE_HOME ||
25
+ (process.platform === 'win32' ? process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local') : path.join(os.homedir(), '.cache'));
26
+ return path.join(base, 'writedocs', 'update-check.json');
27
+ }
28
+
29
+ export function readCache() {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(cacheFile(), 'utf8'));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export function writeCache(data) {
38
+ const file = cacheFile();
39
+ fs.mkdirSync(path.dirname(file), { recursive: true });
40
+ fs.writeFileSync(file, JSON.stringify(data));
41
+ }
42
+
43
+ /** The registry the user's npm uses, or npm's own. */
44
+ export function registryUrl() {
45
+ const configured = process.env.npm_config_registry || process.env.NPM_CONFIG_REGISTRY;
46
+ return (configured || 'https://registry.npmjs.org').replace(/\/+$/, '');
47
+ }
48
+
49
+ /** The version npm's `latest` tag points at. Throws on a network error. */
50
+ export async function fetchLatestVersion(name, { timeoutMs = 5000 } = {}) {
51
+ const res = await fetch(`${registryUrl()}/-/package/${name.replace('/', '%2f')}/dist-tags`, { signal: AbortSignal.timeout(timeoutMs) });
52
+ if (!res.ok) throw new Error(`the registry answered ${res.status}`);
53
+ const tags = await res.json();
54
+ if (typeof tags?.latest !== 'string') throw new Error('the registry has no "latest" version');
55
+ return tags.latest;
56
+ }
57
+
58
+ /** Whether `latest` is a newer release than `current` - x.y.z only; a
59
+ * prerelease never counts as newer. */
60
+ export function isNewer(latest, current) {
61
+ const parse = (v) => /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v).trim())?.slice(1).map(Number);
62
+ const a = parse(latest);
63
+ const b = parse(current);
64
+ if (!a || !b) return false;
65
+ for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] > b[i];
66
+ return false;
67
+ }
68
+
69
+ export function isSourceCheckout(packageRoot) {
70
+ return fs.existsSync(path.join(packageRoot, '.git'));
71
+ }
72
+
73
+ function enabled(command, packageRoot) {
74
+ if (SILENT_COMMANDS.has(command)) return false;
75
+ if (process.env.WRITEDOCS_NO_UPDATE_CHECK || process.env.CI) return false;
76
+ if (!process.stdout.isTTY) return false;
77
+ return !isSourceCheckout(packageRoot);
78
+ }
79
+
80
+ let state = null;
81
+
82
+ /** Called once at startup: loads the cached answer, and refreshes it in the
83
+ * background when it's old. */
84
+ export function startUpdateCheck({ command, name, version, packageRoot }) {
85
+ if (!enabled(command, packageRoot)) return;
86
+ const cache = readCache();
87
+ state = { name, version, latest: cache?.name === name ? cache.latest : null, shown: false };
88
+ if (cache?.name === name && Date.now() - (cache.checkedAt ?? 0) < DAY) return;
89
+ try {
90
+ const worker = path.join(path.dirname(fileURLToPath(import.meta.url)), 'update-check-refresh.js');
91
+ const child = spawn(process.execPath, [worker, name], { detached: true, stdio: 'ignore', windowsHide: true });
92
+ child.unref();
93
+ } catch {
94
+ // No notice this time - not worth failing a command over.
95
+ }
96
+ }
97
+
98
+ /** Prints the notice, if there's a newer version - once per run. */
99
+ export function showUpdateNotice() {
100
+ if (!state || state.shown || !state.latest || !isNewer(state.latest, state.version)) return;
101
+ state.shown = true;
102
+ log.line();
103
+ log.info(`writedocs ${color.bold(state.latest)} is available ${color.dim(`(you have ${state.version})`)}. Run ${color.cyan('writedocs update')} to update.`);
104
+ }