docpensieve 0.5.0-beta.1 → 0.5.0-beta.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.
@@ -34,6 +34,7 @@ program
34
34
  .option('-t, --theme <framework>', 'tailwind | custom')
35
35
  .option('-u, --site-url <url>', 'public URL of the site')
36
36
  .option('--version-name <version>', 'first version, e.g. 1.0')
37
+ .option('--translation <code>', 'code of a second language, e.g. fr')
37
38
  .option('-y, --yes', 'accept the defaults without a dialogue')
38
39
  .option('-f, --force', 'overwrite an existing configuration')
39
40
  .option('--minimal', "leave DocPensieve's documentation out of the new site")
@@ -62,6 +63,7 @@ program
62
63
  .command('dev')
63
64
  .description('Development server with reload')
64
65
  .option('-p, --port <number>', 'listening port', Number, 3000)
66
+ .option('--host <address>', 'address to listen on; 0.0.0.0 opens it to the network', '127.0.0.1')
65
67
  .action(async (options) => {
66
68
  await dev(options);
67
69
  });
@@ -70,6 +72,7 @@ program
70
72
  .command('serve')
71
73
  .description('Serves the output folder statically')
72
74
  .option('-p, --port <number>', 'listening port', Number, 4000)
75
+ .option('--host <address>', 'address to listen on; 0.0.0.0 opens it to the network', '127.0.0.1')
73
76
  .option('-d, --dir <dir>', 'folder to serve')
74
77
  .action(async (options) => {
75
78
  await serve(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docpensieve",
3
- "version": "0.5.0-beta.1",
3
+ "version": "0.5.0-beta.3",
4
4
  "description": "DocPensieve command-line interface (init, build, check, dev, serve)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,10 +24,10 @@
24
24
  "types"
25
25
  ],
26
26
  "dependencies": {
27
- "@docpensieve/components": "0.5.0-beta.1",
28
- "@docpensieve/core": "0.5.0-beta.1",
29
- "@docpensieve/shared": "0.5.0-beta.1",
30
- "@docpensieve/theme": "0.5.0-beta.1",
27
+ "@docpensieve/components": "0.5.0-beta.3",
28
+ "@docpensieve/core": "0.5.0-beta.3",
29
+ "@docpensieve/shared": "0.5.0-beta.3",
30
+ "@docpensieve/theme": "0.5.0-beta.3",
31
31
  "chalk": "^6.0.0",
32
32
  "chokidar": "^5.0.0",
33
33
  "commander": "^15.0.0"
@@ -11,6 +11,7 @@ import {
11
11
  createRegistry,
12
12
  setSiteContext,
13
13
  setAdmonitionKinds,
14
+ setSnippetIcons,
14
15
  } from '@docpensieve/components';
15
16
  import { SiteGenerator, loadConfig, resolveVersion } from '@docpensieve/core';
16
17
 
@@ -28,6 +29,7 @@ export async function build(versionSlug, options = {}) {
28
29
 
29
30
  // The kinds a project declares, beside the ones shipped.
30
31
  setAdmonitionKinds(config.admonitions);
32
+ setSnippetIcons(config.snippetIcons);
31
33
 
32
34
  const generator = new SiteGenerator(config, {
33
35
  // `globalComponents: false` removes the shipped components: a project that
@@ -12,12 +12,20 @@ import {
12
12
  createRegistry,
13
13
  setSiteContext,
14
14
  setAdmonitionKinds,
15
+ setSnippetIcons,
15
16
  } from '@docpensieve/components';
16
17
  import { SiteGenerator, loadConfig } from '@docpensieve/core';
17
18
  import { CONFIG_FILENAME, DocPensieveError, THEME_FOLDER } from '@docpensieve/shared';
18
19
  import chokidar from 'chokidar';
19
20
 
20
- import { RELOAD_PATH, createStaticServer, listen } from '../server.js';
21
+ import {
22
+ DEFAULT_HOST,
23
+ RELOAD_PATH,
24
+ createStaticServer,
25
+ exposedNotice,
26
+ isExposed,
27
+ listen,
28
+ } from '../server.js';
21
29
  import { createTheme } from '../theme.js';
22
30
 
23
31
  /** Default port of `dev`, distinct from that of `serve`. */
@@ -40,7 +48,7 @@ const RELOAD_SCRIPT =
40
48
  `.addEventListener("message",()=>location.reload())</script>`;
41
49
 
42
50
  /**
43
- * @param {{ port?: number, cwd?: string }} [options]
51
+ * @param {{ port?: number, host?: string, cwd?: string }} [options]
44
52
  * @returns {Promise<{
45
53
  * server: import('node:http').Server,
46
54
  * watcher: import('chokidar').FSWatcher,
@@ -62,6 +70,7 @@ export async function dev(options = {}) {
62
70
  // Read again with the rest: a kind added to the configuration must show
63
71
  // without restarting the command.
64
72
  setAdmonitionKinds(current.admonitions);
73
+ setSnippetIcons(current.snippetIcons);
65
74
  const generator = new SiteGenerator(current, {
66
75
  // `globalComponents: false` removes the shipped components: a project
67
76
  // that defines its own thus avoids a name collision. The option was
@@ -87,8 +96,10 @@ export async function dev(options = {}) {
87
96
  },
88
97
  });
89
98
 
90
- const port = await listen(server, options.port ?? DEFAULT_PORT);
99
+ const host = options.host ?? DEFAULT_HOST;
100
+ const port = await listen(server, options.port ?? DEFAULT_PORT, 10, host);
91
101
  const url = `http://localhost:${port}${config.baseUrl}`;
102
+ if (isExposed(host)) console.log(exposedNotice(host));
92
103
  console.log(`served at ${url}`);
93
104
 
94
105
  // The project's own stylesheets, re-read by every rebuild.
@@ -18,6 +18,8 @@ import {
18
18
  DocPensieveError,
19
19
  THEME_FOLDER,
20
20
  THEME_FRAMEWORKS,
21
+ isLanguageCode,
22
+ languageName,
21
23
  } from '@docpensieve/shared';
22
24
 
23
25
  /**
@@ -30,7 +32,16 @@ const FRAMEWORK_LABELS = {
30
32
  };
31
33
 
32
34
  /** Answers used when there is no dialogue. */
33
- const DEFAULTS = { name: 'My documentation', siteUrl: '', theme: 'tailwind', version: '1.0' };
35
+ const DEFAULTS = {
36
+ name: 'My documentation',
37
+ siteUrl: '',
38
+ theme: 'tailwind',
39
+ version: '1.0',
40
+ translation: '',
41
+ };
42
+
43
+ /** Second language offered first, being the one whose wording ships too. */
44
+ const DEFAULT_TRANSLATION = 'fr';
34
45
 
35
46
  /**
36
47
  * Folder of the installed DocPensieve documentation, inside the version folder.
@@ -125,15 +136,43 @@ async function askDocumentation(rl) {
125
136
  }
126
137
  }
127
138
 
139
+ /**
140
+ * Asks whether the site will carry a second language, and which one.
141
+ *
142
+ * Asked rather than left to the configuration: a translation changes the
143
+ * shape of the project — a folder per language beside the pages — and that is
144
+ * cheaper to set up at the start than to retrofit.
145
+ *
146
+ * @param {import('node:readline/promises').Interface} rl
147
+ * @returns {Promise<string>} Language code, or `''` for a single language.
148
+ */
149
+ async function askTranslation(rl) {
150
+ for (;;) {
151
+ const answer = (await rl.question('Will the site be in several languages? [y/N]: '))
152
+ .trim()
153
+ .toLowerCase();
154
+ if (answer === '' || answer === 'n' || answer === 'no') return '';
155
+ if (answer === 'y' || answer === 'yes') break;
156
+ console.log('Answer not understood. Expected: y or n.');
157
+ }
158
+
159
+ for (;;) {
160
+ const code = (await ask(rl, 'Code of the second language', DEFAULT_TRANSLATION)).trim();
161
+ if (isLanguageCode(code)) return code;
162
+ console.log(`"${code}" does not name a language. Expected a code: fr, de, pt-BR, zh-Hans.`);
163
+ }
164
+ }
165
+
128
166
  /**
129
167
  * Gathers the answers, through a dialogue or from the options.
130
168
  *
131
169
  * @param {{
132
170
  * name?: string, theme?: string, siteUrl?: string, version?: string,
133
- * yes?: boolean, minimal?: boolean,
171
+ * translation?: string, yes?: boolean, minimal?: boolean,
134
172
  * }} options
135
173
  * @returns {Promise<{
136
174
  * name: string, theme: string, siteUrl: string, version: string, docs: boolean,
175
+ * translation: string,
137
176
  * }>}
138
177
  */
139
178
  async function collect(options) {
@@ -142,6 +181,7 @@ async function collect(options) {
142
181
  siteUrl: options.siteUrl ?? DEFAULTS.siteUrl,
143
182
  theme: options.theme ?? DEFAULTS.theme,
144
183
  version: options.version ?? DEFAULTS.version,
184
+ translation: options.translation ?? DEFAULTS.translation,
145
185
  docs: !options.minimal,
146
186
  };
147
187
 
@@ -154,7 +194,7 @@ async function collect(options) {
154
194
  if (!process.stdin.isTTY) {
155
195
  console.log('No interactive terminal: no questions asked, the options and defaults apply.');
156
196
  console.log(
157
- 'To choose, pass --name, --site-url, --theme, --version-name or --minimal; --yes silences this notice.',
197
+ 'To choose, pass --name, --site-url, --theme, --version-name, --translation or --minimal; --yes silences this notice.',
158
198
  );
159
199
  return fromOptions;
160
200
  }
@@ -164,9 +204,10 @@ async function collect(options) {
164
204
  const name = await ask(rl, 'Project name', fromOptions.name);
165
205
  const siteUrl = await ask(rl, 'Public URL of the site (optional)', fromOptions.siteUrl);
166
206
  const version = await ask(rl, 'First version', fromOptions.version);
207
+ const translation = options.translation ?? (await askTranslation(rl));
167
208
  const theme = options.theme ?? (await askFramework(rl));
168
209
  const docs = options.minimal ? false : await askDocumentation(rl);
169
- return { name, siteUrl, version, theme, docs };
210
+ return { name, siteUrl, version, theme, docs, translation };
170
211
  } finally {
171
212
  rl.close();
172
213
  }
@@ -183,6 +224,18 @@ async function collect(options) {
183
224
  */
184
225
  const quote = (value) => `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
185
226
 
227
+ /**
228
+ * Writes a language code as a property name.
229
+ *
230
+ * `fr` stands on its own; `pt-BR` and `zh-Hans` carry a hyphen, which is a
231
+ * minus sign to JavaScript — unquoted, the generated configuration would not
232
+ * parse.
233
+ *
234
+ * @param {string} code
235
+ * @returns {string}
236
+ */
237
+ const key = (code) => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(code) ? code : quote(code));
238
+
186
239
  /**
187
240
  * Renders `docpensieve.config.mjs`.
188
241
  *
@@ -190,10 +243,13 @@ const quote = (value) => `'${String(value).replace(/\\/g, '\\\\').replace(/'/g,
190
243
  * or commented out with an example — so that the first file a user opens also
191
244
  * tells them everything they can change.
192
245
  *
193
- * @param {{ name: string, theme: string, siteUrl: string, version: string }} answers
246
+ * @param {{
247
+ * name: string, theme: string, siteUrl: string, version: string,
248
+ * translation?: string,
249
+ * }} answers
194
250
  * @returns {string} Contents of `docpensieve.config.mjs`.
195
251
  */
196
- function renderConfig({ name, theme, siteUrl, version }) {
252
+ function renderConfig({ name, theme, siteUrl, version, translation = '' }) {
197
253
  const slug = versionSlug(version);
198
254
  // A type annotation rather than an `import`: `defineConfig` transforms
199
255
  // nothing, it is only there for autocompletion. Actually importing it would
@@ -233,9 +289,14 @@ function renderConfig({ name, theme, siteUrl, version }) {
233
289
  ` name: ${quote(version)}, // label in the version switcher`,
234
290
  ` folder: ${quote(`docs/${slug}`)},`,
235
291
  ' current: true,',
236
- ' // Pages of this version in another language, served under /fr/.',
292
+ ` // Pages of this version in another language, served under /${translation || 'fr'}/.`,
237
293
  ' // Your own language stays where it is, and keeps its addresses.',
238
- ` // translations: { fr: ${quote(`docs/${slug}-fr`)} },`,
294
+ // Written out, not left as an example, once the language is known: the
295
+ // field is the whole of the feature, and the folder beside it already
296
+ // holds a page.
297
+ translation
298
+ ? ` translations: { ${key(translation)}: ${quote(`docs/${slug}-${translation}`)} },`
299
+ : ` // translations: { fr: ${quote(`docs/${slug}-fr`)} },`,
239
300
  ' },',
240
301
  ' ],',
241
302
  '',
@@ -307,6 +368,16 @@ function renderConfig({ name, theme, siteUrl, version }) {
307
368
  ' // Back-to-top button on every page.',
308
369
  ' scrollToTop: true,',
309
370
  '',
371
+ ' // A button copying each block of code. It is the one thing that makes a',
372
+ ' // content page load a script — a small file, shared by every page of the',
373
+ ' // version — so it stays off until you ask for it.',
374
+ ' copyCode: false,',
375
+ '',
376
+ ' // Icon collection a code block takes the mark of its language from, for',
377
+ " // instance 'simple-icons' once @iconify-json/simple-icons is installed.",
378
+ ' // Left empty, the short name of the language is shown instead.',
379
+ " snippetIcons: '',",
380
+ '',
310
381
  ' // The header stays at the top of the screen. false lets it scroll away',
311
382
  ' // with the page, and gives its height back to the text.',
312
383
  ' stickyHeader: true,',
@@ -353,11 +424,12 @@ function versionSlug(version) {
353
424
  }
354
425
 
355
426
  /**
356
- * @param {string} name Project name, for the title of the home page.
357
- * @param {boolean} docs Whether DocPensieve's documentation is installed.
427
+ * Home page of the project, in the language of the site.
428
+ *
429
+ * @param {{ name: string, docs: boolean, slug: string, translation: string }} answers
358
430
  * @returns {string}
359
431
  */
360
- const renderIndex = (name, docs) => `---
432
+ const renderIndex = ({ name, docs, slug, translation }) => `---
361
433
  title: Introduction
362
434
  description: Documentation of ${name}.
363
435
  date: ${new Date().toISOString().slice(0, 10)}
@@ -378,6 +450,20 @@ without appearing in the URL.
378
450
 
379
451
  See the [installation guide](/guide/installation/).
380
452
  ${
453
+ translation
454
+ ? `
455
+ ## In ${languageName(translation)}
456
+
457
+ The same pages live in \`docs/${slug}-${translation}/\`, and the language switcher
458
+ in the header moves between them.
459
+
460
+ A page with no twin there does not exist in that language: it stays out of the
461
+ menu and out of the sitemap, and the switcher names the language without
462
+ offering it. The installation page is in that case — write
463
+ \`docs/${slug}-${translation}/01-guide/01-installation.md\` and it appears.
464
+ ${docs ? '\nThe [languages guide](/docpensieve/guide/languages/) covers the rest.\n' : ''}`
465
+ : ''
466
+ }${
381
467
  docs
382
468
  ? `
383
469
  ## Learning DocPensieve
@@ -389,6 +475,70 @@ tool that builds this site, installed along with it. Delete its folder,
389
475
  : ''
390
476
  }`;
391
477
 
478
+ /**
479
+ * Home page of the second language.
480
+ *
481
+ * French is written out, the tool shipping its wording too. Any other
482
+ * language gets the page in English, saying in its first line that it is
483
+ * there to be translated: a copy passing for a translation is the one failure
484
+ * this feature invites — the reader gets English under an address that
485
+ * promised their language, and nothing reports it.
486
+ *
487
+ * Only the home page is written. Its twin, the installation page, is left
488
+ * untranslated on purpose: it is what shows that an untranslated page does
489
+ * not exist in that language, which no sentence explains as well as the menu
490
+ * that lacks it.
491
+ *
492
+ * @param {{ name: string, slug: string, translation: string }} answers
493
+ * @returns {string}
494
+ */
495
+ function renderTranslatedIndex({ name, slug, translation }) {
496
+ const french = translation.toLowerCase().split('-')[0] === 'fr';
497
+ const head = `---
498
+ title: Introduction
499
+ description: ${french ? `Documentation de ${name}.` : `Documentation of ${name}.`}
500
+ date: ${new Date().toISOString().slice(0, 10)}
501
+
502
+ jsonld:
503
+ type: TechArticle
504
+ breadcrumbs: true
505
+ ---
506
+
507
+ # ${name}
508
+ `;
509
+
510
+ if (french) {
511
+ return `${head}
512
+ Bienvenue dans la documentation.
513
+
514
+ ## Une page et sa jumelle
515
+
516
+ Cette page est la version française de \`docs/${slug}/index.md\`. Le sélecteur
517
+ de langue, dans l'en-tête, passe de l'une à l'autre.
518
+
519
+ La page d'installation, elle, n'est pas traduite : elle **n'existe pas** en
520
+ français. Elle ne figure ni dans le menu ni dans le plan du site, et le
521
+ sélecteur la nomme sans la proposer. Écrivez
522
+ \`docs/${slug}-${translation}/01-guide/01-installation.md\` pour la voir apparaître.
523
+ `;
524
+ }
525
+
526
+ return `${head}
527
+ **Replace this page with your translation.** It is the ${languageName(translation)}
528
+ twin of \`docs/${slug}/index.md\`, written in English so that the site builds:
529
+ left as it is, a reader who picks ${languageName(translation, translation)} gets English.
530
+
531
+ ## One page, two languages
532
+
533
+ The language switcher in the header moves between this page and its twin.
534
+
535
+ The installation page has no twin here, so it does not exist in this language:
536
+ it stays out of the menu and out of the sitemap, and the switcher names the
537
+ language without offering it. Write
538
+ \`docs/${slug}-${translation}/01-guide/01-installation.md\` and it appears.
539
+ `;
540
+ }
541
+
392
542
  /** @returns {string} Sample page, showing ordering and highlighting. */
393
543
  const renderGuide = () => `---
394
544
  title: Installation
@@ -478,11 +628,14 @@ async function installDocumentation(source, target, slug) {
478
628
  * @param {string} [dir] Target folder, created if needed.
479
629
  * @param {{
480
630
  * name?: string, theme?: string, siteUrl?: string, version?: string,
481
- * yes?: boolean, force?: boolean, minimal?: boolean,
482
- * }} [options] `minimal` leaves DocPensieve's documentation out of the site.
483
- * @returns {Promise<{ dir: string, theme: string, docs: boolean }>}
484
- * @throws {DocPensieveError} Unknown framework, project already initialised,
485
- * or documentation to install missing.
631
+ * translation?: string, yes?: boolean, force?: boolean, minimal?: boolean,
632
+ * }} [options] `minimal` leaves DocPensieve's documentation out of the site;
633
+ * `translation` is the code of a second language, `fr` for instance.
634
+ * @returns {Promise<{
635
+ * dir: string, theme: string, docs: boolean, translation: string,
636
+ * }>}
637
+ * @throws {DocPensieveError} Unknown framework, unknown language, project
638
+ * already initialised, or documentation to install missing.
486
639
  */
487
640
  export async function init(dir = '.', options = {}) {
488
641
  const target = path.resolve(dir);
@@ -504,6 +657,14 @@ export async function init(dir = '.', options = {}) {
504
657
  });
505
658
  }
506
659
 
660
+ // Checked here for the same reason as the framework: a code refused after
661
+ // five questions would be five questions wasted.
662
+ if (options.translation && !isLanguageCode(options.translation)) {
663
+ throw new DocPensieveError(`"${options.translation}" does not name a language.`, {
664
+ hint: 'Write the code, not the name: "fr" for French, "pt-BR", "zh-Hans". It becomes the lang of the document and a segment of the address.',
665
+ });
666
+ }
667
+
507
668
  const answers = await collect(options);
508
669
 
509
670
  // Located before anything is written: a project left half set up would be
@@ -535,8 +696,21 @@ export async function init(dir = '.', options = {}) {
535
696
 
536
697
  await mkdir(path.join(docsDir, '01-guide'), { recursive: true });
537
698
  await writeFile(configPath, renderConfig(answers), 'utf8');
538
- await writeFile(path.join(docsDir, 'index.md'), renderIndex(answers.name, answers.docs), 'utf8');
699
+ await writeFile(
700
+ path.join(docsDir, 'index.md'),
701
+ renderIndex({ name: answers.name, docs: answers.docs, slug, translation: answers.translation }),
702
+ 'utf8',
703
+ );
539
704
  await writeFile(path.join(docsDir, '01-guide', '01-installation.md'), renderGuide(), 'utf8');
705
+ if (answers.translation) {
706
+ const folder = path.join(target, 'docs', `${slug}-${answers.translation}`);
707
+ await mkdir(folder, { recursive: true });
708
+ await writeFile(
709
+ path.join(folder, 'index.md'),
710
+ renderTranslatedIndex({ name: answers.name, slug, translation: answers.translation }),
711
+ 'utf8',
712
+ );
713
+ }
540
714
  if (documentation) {
541
715
  await installDocumentation(documentation, path.join(docsDir, DOCS_FOLDER), slug);
542
716
  }
@@ -548,14 +722,31 @@ export async function init(dir = '.', options = {}) {
548
722
  console.log(` ${CONFIG_FILENAME}`);
549
723
  console.log(` docs/${slug}/index.md`);
550
724
  console.log(` docs/${slug}/01-guide/01-installation.md`);
725
+ if (answers.translation) {
726
+ const state =
727
+ answers.translation.toLowerCase().split('-')[0] === 'fr'
728
+ ? `in ${languageName(answers.translation)}`
729
+ : 'to translate';
730
+ console.log(` docs/${slug}-${answers.translation}/index.md the home page, ${state}`);
731
+ }
551
732
  if (documentation) {
552
733
  console.log(` docs/${slug}/${DOCS_FOLDER}/ DocPensieve's documentation, to delete when done`);
553
734
  }
554
735
  for (const line of stylesheets) console.log(` ${line}`);
555
736
  console.log(`\nTheme: ${answers.theme} — ${FRAMEWORK_LABELS[answers.theme]}`);
737
+ if (answers.translation) {
738
+ console.log(
739
+ `Second language: ${languageName(answers.translation)} (${answers.translation}) — served under /${answers.translation}/`,
740
+ );
741
+ }
556
742
  console.log('\nNext: npx docpensieve dev');
557
743
 
558
- return { dir: target, theme: answers.theme, docs: answers.docs };
744
+ return {
745
+ dir: target,
746
+ theme: answers.theme,
747
+ docs: answers.docs,
748
+ translation: answers.translation,
749
+ };
559
750
  }
560
751
 
561
752
  /**
@@ -10,13 +10,13 @@ import path from 'node:path';
10
10
  import { loadConfig } from '@docpensieve/core';
11
11
  import { DocPensieveError } from '@docpensieve/shared';
12
12
 
13
- import { createStaticServer, listen } from '../server.js';
13
+ import { DEFAULT_HOST, createStaticServer, exposedNotice, isExposed, listen } from '../server.js';
14
14
 
15
15
  /** Default port of `serve`, distinct from that of `dev`. */
16
16
  const DEFAULT_PORT = 4000;
17
17
 
18
18
  /**
19
- * @param {{ port?: number, dir?: string, cwd?: string }} [options]
19
+ * @param {{ port?: number, host?: string, dir?: string, cwd?: string }} [options]
20
20
  * @returns {Promise<{ server: import('node:http').Server, port: number, url: string }>}
21
21
  * @throws {DocPensieveError} When the folder to serve does not exist.
22
22
  */
@@ -34,11 +34,13 @@ export async function serve(options = {}) {
34
34
  // The site is mounted under the configuration's baseUrl: otherwise the links
35
35
  // of the generated pages would not resolve locally.
36
36
  const server = createStaticServer({ root, basePath: config.baseUrl });
37
- const port = await listen(server, options.port ?? DEFAULT_PORT);
37
+ const host = options.host ?? DEFAULT_HOST;
38
+ const port = await listen(server, options.port ?? DEFAULT_PORT, 10, host);
38
39
  const url = `http://localhost:${port}${config.baseUrl}`;
39
40
 
40
41
  console.log(`${root}`);
41
42
  console.log(`served at ${url}`);
43
+ if (isExposed(host)) console.log(exposedNotice(host));
42
44
 
43
45
  return { server, port, url };
44
46
  }
package/src/server.js CHANGED
@@ -162,16 +162,35 @@ export function createStaticServer({ root, basePath = '/', inject = null, onRelo
162
162
  });
163
163
  }
164
164
 
165
+ /** The loopback address: the site is served to this machine and to no other. */
166
+ export const DEFAULT_HOST = '127.0.0.1';
167
+
168
+ /**
169
+ * Whether an address reaches beyond this machine.
170
+ *
171
+ * @param {string} host
172
+ * @returns {boolean}
173
+ */
174
+ export function isExposed(host) {
175
+ return !['127.0.0.1', 'localhost', '::1'].includes(host);
176
+ }
177
+
165
178
  /**
166
179
  * Starts listening, looking for a free port if needed.
167
180
  *
181
+ * On this machine only, unless asked otherwise. Called without a host, Node
182
+ * listens on every interface: anyone on the same network — a café, a
183
+ * coworking space — could read the site being written, drafts included.
184
+ *
168
185
  * @param {import('node:http').Server} server
169
186
  * @param {number} port Desired port.
170
187
  * @param {number} [attempts] Number of ports tried from `port` on.
188
+ * @param {string} [host] Address to listen on. `0.0.0.0` opens it to the
189
+ * network, which a phone on the same wifi needs.
171
190
  * @returns {Promise<number>} The port actually used.
172
191
  * @throws {DocPensieveError} When no port is free in the range.
173
192
  */
174
- export function listen(server, port, attempts = 10) {
193
+ export function listen(server, port, attempts = 10, host = DEFAULT_HOST) {
175
194
  return new Promise((resolve, reject) => {
176
195
  let current = port;
177
196
 
@@ -181,7 +200,7 @@ export function listen(server, port, attempts = 10) {
181
200
  // the next one beats forcing --port.
182
201
  if (error.code === 'EADDRINUSE' && current < port + attempts - 1) {
183
202
  current += 1;
184
- server.listen(current);
203
+ server.listen(current, host);
185
204
  return;
186
205
  }
187
206
  server.off('error', onError);
@@ -202,6 +221,17 @@ export function listen(server, port, attempts = 10) {
202
221
  const address = server.address();
203
222
  resolve(typeof address === 'object' && address !== null ? address.port : current);
204
223
  });
205
- server.listen(current);
224
+ server.listen(current, host);
206
225
  });
207
226
  }
227
+
228
+ /**
229
+ * Said when the site is opened to the network, so that it is never done
230
+ * without the person knowing.
231
+ *
232
+ * @param {string} host
233
+ * @returns {string}
234
+ */
235
+ export function exposedNotice(host) {
236
+ return `listening on ${host}: anyone on this network can read the site.`;
237
+ }
@@ -46,6 +46,7 @@ npx docpensieve init my-site --yes --name "My documentation"
46
46
  | `-t, --theme <framework>` | `tailwind` or `custom` |
47
47
  | `-u, --site-url <url>` | Public URL, from which the deployment prefix is derived |
48
48
  | `--version-name <version>` | First version, `1.0` for instance |
49
+ | `--translation <code>` | Code of a second language, `fr` for instance |
49
50
  | `-y, --yes` | Accepts the defaults without a dialogue |
50
51
  | `-f, --force` | Overwrites an existing configuration |
51
52
  | `--minimal` | Leaves DocPensieve's documentation out of the site |
@@ -98,6 +98,10 @@ online.
98
98
  → ignores the deployment prefix "/my-project/"
99
99
  ```
100
100
 
101
+ Two commands, and the second reads what the first wrote — so they belong
102
+ together. [Deployment](./deployment/) shows how to say that once, in a
103
+ `package.json`, and have a workflow run the same thing you run.
104
+
101
105
  It is the check to run after any change of `baseUrl`, of folder structure or of
102
106
  URL. When something does not add up, [When it breaks](./when-it-breaks/) lists
103
107
  the failures this tool produces, by the symptom you see.
@@ -88,6 +88,32 @@ the tool through `npx`, which takes the latest published version. To pin it
88
88
  from one build to the next, declare `docpensieve` in a `package.json` and add
89
89
  `npm ci` before the build.
90
90
 
91
+ ## One command, for you and for the machine
92
+
93
+ Two commands are one too many to remember, and a workflow should run exactly
94
+ what you run. A `package.json` is the place to say it once:
95
+
96
+ ```json
97
+ {
98
+ "scripts": {
99
+ "docs": "docpensieve dev",
100
+ "check": "docpensieve build && docpensieve check"
101
+ }
102
+ }
103
+ ```
104
+
105
+ `npm run check` then generates the site and reads it back. A dead link, a
106
+ nesting the markup cannot carry, a page that stops the build: each one fails
107
+ the command, on your machine and in your workflow, in the same way and with
108
+ the same message.
109
+
110
+ The `&&` is the point of it. `check` reads what `build` wrote, so a check run
111
+ on its own tells you about the site as it was the last time it was generated —
112
+ which, the day you forget, is the day it matters.
113
+
114
+ With that in place the recipe above becomes `npm ci` and `npm run check`, and
115
+ the version of the tool stops depending on the day the workflow ran.
116
+
91
117
  ## Reading back before publishing
92
118
 
93
119
  A generated site can compile without error and contain dead links.
@@ -31,8 +31,23 @@ npx docpensieve check
31
31
 
32
32
  ## What changes on its own
33
33
 
34
- Nothing yet: the 0.5 has just opened. Each change is listed here as it lands,
35
- beside what it asks of a project already built on the 0.4.
34
+ - **`dev` and `serve` listen on this machine only.** They used to listen on
35
+ every interface, so anyone on the same network could read the site being
36
+ written. To look at it from a phone on the same wifi, say so:
37
+ `npx docpensieve dev --host 0.0.0.0`.
38
+ - **Code blocks carry a background of their own.** A block in a language the
39
+ highlighter does not know used to sit on the bare page; it now looks like
40
+ every other block.
41
+ - **An error keeps its hint.** An error the tool raises while a page compiles
42
+ no longer comes out as a generic syntax complaint.
43
+
44
+ ## What is worth turning on
45
+
46
+ - [Snippet](../components/snippet/), to show a real file rather than a copy.
47
+ - [Hero](../components/hero/) and [Calendar](../components/calendar/).
48
+ - `copyCode`, a copy button on every code block — off by default, since it is
49
+ what makes a page load a script. See [the reference](../reference/configuration/).
50
+ - `snippetIcons`, if your project installs an icon collection.
36
51
 
37
52
  ## What to check afterwards
38
53