@inditextech/docouture-cli 0.1.0-SNAPSHOT.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/build/bin.js +321 -0
  2. package/build/commands/build.js +22 -0
  3. package/build/commands/completion.js +69 -0
  4. package/build/commands/dev.js +67 -0
  5. package/build/commands/doctor.js +156 -0
  6. package/build/commands/eject.js +71 -0
  7. package/build/commands/new.js +512 -0
  8. package/build/commands/publish.js +111 -0
  9. package/build/commands/teardown.js +66 -0
  10. package/build/commands/upgrade.js +156 -0
  11. package/build/commands/version.js +60 -0
  12. package/build/lib/agents-md.js +67 -0
  13. package/build/lib/antora-log.js +36 -0
  14. package/build/lib/antora-yml.js +47 -0
  15. package/build/lib/args.js +26 -0
  16. package/build/lib/cli-context.js +17 -0
  17. package/build/lib/cli-info.js +20 -0
  18. package/build/lib/config-resolver.js +23 -0
  19. package/build/lib/copy-template.js +162 -0
  20. package/build/lib/debug-log.js +21 -0
  21. package/build/lib/detect-package-manager.js +79 -0
  22. package/build/lib/dev-server.js +337 -0
  23. package/build/lib/doctor-checks.js +240 -0
  24. package/build/lib/global-flags.js +31 -0
  25. package/build/lib/kroki-compose.js +54 -0
  26. package/build/lib/playbook-yml.js +90 -0
  27. package/build/lib/repo-root.js +21 -0
  28. package/build/lib/run-script.js +50 -0
  29. package/build/lib/theme.js +51 -0
  30. package/build/templates/agent-support/.claude/skills/docs-internals/SKILL.md +53 -0
  31. package/build/templates/agent-support/.claude/skills/docs-internals/reference/antora-extensions.md +54 -0
  32. package/build/templates/agent-support/.claude/skills/docs-internals/reference/naming.md +41 -0
  33. package/build/templates/agent-support/.claude/skills/docs-internals/reference/page-patterns.md +238 -0
  34. package/build/templates/agent-support/.claude/skills/docs-versioning.versioned/SKILL.md +37 -0
  35. package/build/templates/agent-support/.claude/skills/docs-versioning.versioned/reference/releasing.md +106 -0
  36. package/build/templates/agent-support/.claude/skills/documenting-your-repo/SKILL.md +55 -0
  37. package/build/templates/agent-support/.claude/skills/documenting-your-repo/reference/content-sourcing.md +55 -0
  38. package/build/templates/agent-support/.claude/skills/documenting-your-repo/reference/maintenance-loop.md +68 -0
  39. package/build/templates/agent-support/.claude/skills/documenting-your-repo/reference/structure-planning.md +102 -0
  40. package/build/templates/agent-support/.claude/skills/writing-docs-pages/SKILL.md +112 -0
  41. package/build/templates/agent-support/.claude/skills/writing-docs-pages/reference/docouture-blocks.md +170 -0
  42. package/build/templates/agent-support/.claude/skills/writing-docs-pages/reference/language-basics.md +402 -0
  43. package/build/templates/agent-support/.opencode/skills/docs-internals/SKILL.md +53 -0
  44. package/build/templates/agent-support/.opencode/skills/docs-internals/reference/antora-extensions.md +54 -0
  45. package/build/templates/agent-support/.opencode/skills/docs-internals/reference/naming.md +41 -0
  46. package/build/templates/agent-support/.opencode/skills/docs-internals/reference/page-patterns.md +238 -0
  47. package/build/templates/agent-support/.opencode/skills/docs-versioning.versioned/SKILL.md +37 -0
  48. package/build/templates/agent-support/.opencode/skills/docs-versioning.versioned/reference/releasing.md +106 -0
  49. package/build/templates/agent-support/.opencode/skills/documenting-your-repo/SKILL.md +55 -0
  50. package/build/templates/agent-support/.opencode/skills/documenting-your-repo/reference/content-sourcing.md +55 -0
  51. package/build/templates/agent-support/.opencode/skills/documenting-your-repo/reference/maintenance-loop.md +68 -0
  52. package/build/templates/agent-support/.opencode/skills/documenting-your-repo/reference/structure-planning.md +102 -0
  53. package/build/templates/agent-support/.opencode/skills/writing-docs-pages/SKILL.md +112 -0
  54. package/build/templates/agent-support/.opencode/skills/writing-docs-pages/reference/docouture-blocks.md +170 -0
  55. package/build/templates/agent-support/.opencode/skills/writing-docs-pages/reference/language-basics.md +402 -0
  56. package/build/templates/agent-support/AGENTS.md +72 -0
  57. package/build/templates/starter/antora-playbook.local.yml +99 -0
  58. package/build/templates/starter/antora-playbook.versioned.yml +167 -0
  59. package/build/templates/starter/antora-playbook.yml +189 -0
  60. package/build/templates/starter/gitignore +3 -0
  61. package/build/templates/starter/package.json +37 -0
  62. package/build/templates/starter/scripts/check-links.mjs +178 -0
  63. package/build/templates/starter/src/antora.yml +85 -0
  64. package/build/templates/starter/src/modules/ROOT/images/card-placeholder.png +0 -0
  65. package/build/templates/starter/src/modules/ROOT/images/feature-placeholder-dark.png +0 -0
  66. package/build/templates/starter/src/modules/ROOT/images/feature-placeholder.png +0 -0
  67. package/build/templates/starter/src/modules/ROOT/images/hero-placeholder.png +0 -0
  68. package/build/templates/starter/src/modules/ROOT/pages/index.adoc +124 -0
  69. package/build/templates/starter/src/modules/main/nav.adoc +19 -0
  70. package/build/templates/starter/src/modules/main/pages/app.adoc +5 -0
  71. package/build/templates/starter/src/modules/main/pages/architecture.adoc +6 -0
  72. package/build/templates/starter/src/modules/main/pages/changelog/index.adoc +9 -0
  73. package/build/templates/starter/src/modules/main/pages/components.adoc +701 -0
  74. package/build/templates/starter/src/modules/main/pages/contributing.adoc +6 -0
  75. package/build/templates/starter/src/modules/main/pages/getting-started.adoc +169 -0
  76. package/build/templates/starter/src/modules/main/pages/glossary.adoc +8 -0
  77. package/build/templates/starter/src/modules/main/pages/index.adoc +9 -0
  78. package/build/templates/starter/src/modules/main/pages/prerequisites.adoc +55 -0
  79. package/build/templates/starter/src/modules/main/pages/quickstart.adoc +21 -0
  80. package/build/templates/starter/src/modules/main/pages/release-notes/index.adoc +9 -0
  81. package/build/templates/starter/src/modules/main/pages/requirements.adoc +8 -0
  82. package/build/templates/starter/src/modules/main/pages/roadmap.adoc +4 -0
  83. package/build/templates/starter/src/modules/main/pages/structure.adoc +5 -0
  84. package/build/templates/starter/src/release-version.versioned +1 -0
  85. package/build/templates/starter/supplemental-ui/favicon.ico +0 -0
  86. package/build/templates/starter/supplemental-ui/product-logo-dark.png +0 -0
  87. package/build/templates/starter/supplemental-ui/product-logo.png +0 -0
  88. package/build/templates/workflows/docouture-kroki-cache-warm.yml +101 -0
  89. package/build/templates/workflows/docouture-pr-verify.yml +120 -0
  90. package/build/templates/workflows/docouture-publish-prerelease.yml +122 -0
  91. package/build/templates/workflows/docouture-publish.yml +138 -0
  92. package/build/templates/workflows/docouture-release-preview.yml +198 -0
  93. package/build/templates/workflows/docouture-release.yml +414 -0
  94. package/package.json +38 -0
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+ import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ const PLACEHOLDERS = {
5
+ __DOCOUTURE_NAME__: 'name',
6
+ __DOCOUTURE_TITLE__: 'title',
7
+ __DOCOUTURE_COMPONENT_NAME__: 'componentName',
8
+ __DOCOUTURE_CLI_VERSION__: 'cliVersion',
9
+ __DOCOUTURE_PM__: 'pmName',
10
+ __DOCOUTURE_PM_CACHE__: 'pmCacheName',
11
+ __DOCOUTURE_LOCKFILE__: 'pmLockfile',
12
+ __DOCOUTURE_INSTALL_CI__: 'pmCiCmd',
13
+ // The whole comment line (leading spaces, `#`, trailing newline) is the
14
+ // token here, not just the bare placeholder name — a bare
15
+ // `__DOCOUTURE_PM_SETUP_STEP__` sitting at the start of a YAML line with a
16
+ // step's own `- name: …` right after it on the same line is not valid YAML
17
+ // on its own (before substitution), which is exactly the form these
18
+ // template files are in until `docouture new` runs — and `just fmt`/prettier
19
+ // parses them as real YAML. A `#`-prefixed placeholder is a comment,
20
+ // valid on any line, so the *template* stays parseable; substituting the
21
+ // whole line (not just the token inside it) is what lets the pnpm-only
22
+ // value (which ends in its own `\n` — see packageManagerPlan) or the
23
+ // empty npm value drop cleanly in its place.
24
+ ' # __DOCOUTURE_PM_SETUP_STEP__\n': 'pmSetupStepYaml',
25
+ // A bare token this time, unlike pmSetupStepYaml above — see
26
+ // TemplateValues.repoIgnoreGlob's own comment for why the whole-segment
27
+ // trick doesn't survive here.
28
+ __DOCOUTURE_REPO_IGNORE_GLOB__: 'repoIgnoreGlob',
29
+ };
30
+ // `<!-- prettier-ignore -->` directives exist only to stop prettier mangling a
31
+ // placeholder token in the template source (e.g. `__DOCOUTURE_TITLE__` inside a
32
+ // markdown heading, which double-underscore emphasis would otherwise rewrite
33
+ // to `**DOCOUTURE_TITLE**`) — they are not meant to survive into scaffolded
34
+ // output, where the placeholder has already been substituted away.
35
+ const PRETTIER_IGNORE_LINE = /^<!-- prettier-ignore -->\n/m;
36
+ function substitute(text, values) {
37
+ let out = text.replace(PRETTIER_IGNORE_LINE, '');
38
+ for (const [token, key] of Object.entries(PLACEHOLDERS)) {
39
+ out = out.split(token).join(values[key]);
40
+ }
41
+ return out;
42
+ }
43
+ // A `.versioned` marker in a template filename (e.g.
44
+ // `antora-playbook.versioned.yml` beside `antora-playbook.yml`, or
45
+ // `release-version.versioned` for a file with no natural extension to hang a
46
+ // mid-name marker off) is a versioning-mode override — see
47
+ // `writeTemplateFile`, which reads one explicitly to lay a versioned-mode
48
+ // file down under its real name when `docouture new --mode versioned` is used.
49
+ // It must never be copied under its own literal name by the generic walk
50
+ // below, standalone or not.
51
+ const VERSIONED_MARKER = '.versioned';
52
+ // AGENTS.md needs its own merge logic (see lib/agents-md.ts) rather than a
53
+ // blind overwrite — a repository may already have its own AGENTS.md, or a
54
+ // human may have added notes around docouture' own section since the last
55
+ // `docouture new`/`docouture upgrade`. `new.ts`/`upgrade.ts` read, merge and write
56
+ // it themselves; the generic walk below must skip over it entirely rather
57
+ // than clobbering it the same way it does every other template file.
58
+ const SKIP_FILENAMES = new Set(['AGENTS.md']);
59
+ // npm's publish step unconditionally strips any file matching `.git*`
60
+ // (.gitignore, .gitattributes, .gitmodules, ...) from a package's tarball,
61
+ // regardless of the `files` field or an .npmignore — confirmed here with
62
+ // `npm pack --dry-run` on this very package, which silently drops
63
+ // templates/starter/.gitignore while every sibling file in that directory
64
+ // survives. That means a `.gitignore` template only ever worked when
65
+ // `docouture new` ran from a repo checkout, never from an npm-installed copy —
66
+ // the real-world case. The fix (the same one create-react-app's own
67
+ // template uses): the template source file has no leading dot at all
68
+ // (`gitignore`), and this map renames it back to its real name only at
69
+ // write time, after npm has already packed it safely.
70
+ const DOTFILE_RENAMES = {
71
+ gitignore: '.gitignore',
72
+ };
73
+ // Extensions copied as raw bytes, no placeholder substitution — logo and
74
+ // favicon assets under `supplemental-ui/` (GH-114). Substituting text tokens
75
+ // into a binary file via `readFile(from, 'utf8')` corrupts it (the bytes get
76
+ // decoded as UTF-8 and re-encoded on write), so these must take a separate,
77
+ // non-text path instead of the generic one below.
78
+ const BINARY_EXTENSIONS = new Set(['.png', '.ico', '.jpg', '.jpeg', '.gif', '.webp']);
79
+ function isBinaryFile(name) {
80
+ const dot = name.lastIndexOf('.');
81
+ if (dot === -1)
82
+ return false;
83
+ return BINARY_EXTENSIONS.has(name.slice(dot).toLowerCase());
84
+ }
85
+ // Copies every file under `srcDir` into `destDir`, substituting placeholder
86
+ // tokens in each text file. Most template files are plain text (YAML,
87
+ // AsciiDoc, JSON) and go through that substitution; image assets
88
+ // (BINARY_EXTENSIONS) are copied as raw bytes instead — see its own comment.
89
+ // Returns every destination file path written (or, under `dryRun`, that
90
+ // would have been written) — `new.ts` ignores this, `upgrade.ts` uses it
91
+ // both to report progress and to preview a dry run.
92
+ export async function copyTemplate(srcDir, destDir, values, opts = {}) {
93
+ const entries = await readdir(srcDir, { withFileTypes: true });
94
+ const written = [];
95
+ if (!opts.dryRun) {
96
+ await mkdir(destDir, { recursive: true });
97
+ }
98
+ for (const entry of entries) {
99
+ if (entry.name.includes(VERSIONED_MARKER))
100
+ continue;
101
+ if (SKIP_FILENAMES.has(entry.name))
102
+ continue;
103
+ const from = join(srcDir, entry.name);
104
+ const to = join(destDir, DOTFILE_RENAMES[entry.name] ?? entry.name);
105
+ if (entry.isDirectory()) {
106
+ written.push(...(await copyTemplate(from, to, values, opts)));
107
+ }
108
+ else if (opts.dryRun) {
109
+ written.push(to);
110
+ }
111
+ else if (isBinaryFile(entry.name)) {
112
+ const content = await readFile(from);
113
+ await writeFile(to, content);
114
+ written.push(to);
115
+ }
116
+ else {
117
+ const content = await readFile(from, 'utf8');
118
+ await writeFile(to, substitute(content, values), 'utf8');
119
+ written.push(to);
120
+ }
121
+ }
122
+ return written;
123
+ }
124
+ // Overwrites a single already-copied file with a versioning-mode override —
125
+ // used for `antora-playbook.yml` and `docs/.release-version`, whose
126
+ // versioned-mode shape (see reference/versioning-modes.md) differs from the
127
+ // standalone default copyTemplate above lays down (or, for
128
+ // `.release-version`, does not lay down at all). `docs/antora.yml` is NOT
129
+ // overridden this way — it is identical for both modes (see
130
+ // templates/starter/docs/antora.yml's own comment). Same placeholder
131
+ // substitution, just for one file instead of a whole tree.
132
+ export async function writeTemplateFile(srcFile, destFile, values) {
133
+ const content = await readFile(srcFile, 'utf8');
134
+ await writeFile(destFile, substitute(content, values), 'utf8');
135
+ }
136
+ // Renders a single template file's placeholder-substituted content without
137
+ // writing it anywhere — used for AGENTS.md (see lib/agents-md.ts), which
138
+ // `new.ts`/`upgrade.ts` need as a string to merge against whatever's already
139
+ // on disk, rather than a file copyTemplate can just write directly.
140
+ export async function renderTemplateFile(srcFile, values) {
141
+ const content = await readFile(srcFile, 'utf8');
142
+ return substitute(content, values);
143
+ }
144
+ export async function isEmptyOrMissing(dir) {
145
+ try {
146
+ const entries = await readdir(dir);
147
+ return entries.length === 0;
148
+ }
149
+ catch {
150
+ return true;
151
+ }
152
+ }
153
+ export async function exists(path) {
154
+ try {
155
+ await stat(path);
156
+ return true;
157
+ }
158
+ catch {
159
+ return false;
160
+ }
161
+ }
162
+ //# sourceMappingURL=copy-template.js.map
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+ import { getContext } from './cli-context.js';
3
+ // Node.js CLI convention (see the debug package, and most CLIs that follow
4
+ // it): DEBUG=docouture or DEBUG=* turns this on from the environment, the same
5
+ // as --verbose does from a flag — either is enough. Always written to
6
+ // stderr, never stdout, so it never contaminates a command's actual output
7
+ // (in particular `docouture doctor --json`'s piped-and-parsed JSON).
8
+ function enabled() {
9
+ if (getContext().verbose)
10
+ return true;
11
+ const debug = process.env.DEBUG;
12
+ if (!debug)
13
+ return false;
14
+ return debug.split(',').some((token) => token.trim() === 'docouture' || token.trim() === '*');
15
+ }
16
+ export function debugLog(message) {
17
+ if (!enabled())
18
+ return;
19
+ console.error(`[docouture debug] ${message}`);
20
+ }
21
+ //# sourceMappingURL=debug-log.js.map
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ const PNPM_ACTION_SETUP_STEP = ' - name: Setup pnpm\n' +
5
+ ' uses: pnpm/action-setup@a3252b78c470c02f7abfb6128aa3c1852013d47c # v4\n' +
6
+ '\n';
7
+ // Reads the invoking package manager off npm's own user-agent env var — set
8
+ // by npm, pnpm and yarn alike on every script/exec they run, e.g.
9
+ // 'pnpm/9.1.0 npm/? node/v20.11.0 darwin x64'. This is how `docouture new` was
10
+ // actually invoked (`npx @inditextech/docouture-cli` vs `pnpm dlx
11
+ // @inditextech/docouture-cli`) — the best signal available for a brand-new
12
+ // repository with no lockfile of its own yet.
13
+ function fromUserAgent() {
14
+ const ua = process.env.npm_config_user_agent;
15
+ if (!ua)
16
+ return null;
17
+ if (ua.startsWith('pnpm/'))
18
+ return 'pnpm';
19
+ if (ua.startsWith('yarn/'))
20
+ return 'npm'; // yarn isn't a supported output here — fall through to npm.
21
+ return 'npm';
22
+ }
23
+ // Best-effort: an existing repository's own package.json#packageManager
24
+ // field (the corepack convention, e.g. "pnpm@9.1.0") wins over anything
25
+ // inferred, since it's an explicit declaration rather than a guess.
26
+ function fromPackageManagerField(targetDir) {
27
+ try {
28
+ const raw = readFileSync(join(targetDir, 'package.json'), 'utf8');
29
+ const pkg = JSON.parse(raw);
30
+ if (typeof pkg.packageManager === 'string' && pkg.packageManager.startsWith('pnpm@'))
31
+ return 'pnpm';
32
+ if (typeof pkg.packageManager === 'string' && pkg.packageManager.startsWith('npm@'))
33
+ return 'npm';
34
+ }
35
+ catch {
36
+ // No package.json, unreadable, or no such field — fall through.
37
+ }
38
+ return null;
39
+ }
40
+ function fromLockfile(targetDir) {
41
+ if (existsSync(join(targetDir, 'pnpm-lock.yaml')) || existsSync(join(targetDir, 'pnpm-workspace.yaml'))) {
42
+ return 'pnpm';
43
+ }
44
+ return null;
45
+ }
46
+ /**
47
+ * Which package manager `docouture new`'s printed next-steps (and the
48
+ * package-manager-aware bits of the scaffolded workflow templates) should
49
+ * assume for this repository. Checked in order of how much it's worth
50
+ * trusting: an explicit `packageManager` field first, then a lockfile
51
+ * already committed at the repo root, then how `docouture new` itself was
52
+ * invoked, defaulting to npm when none of those say otherwise.
53
+ */
54
+ export function detectPackageManager(targetDir) {
55
+ return fromPackageManagerField(targetDir) ?? fromLockfile(targetDir) ?? fromUserAgent() ?? 'npm';
56
+ }
57
+ export function packageManagerPlan(pm) {
58
+ if (pm === 'pnpm') {
59
+ return {
60
+ pm,
61
+ installCmd: 'pnpm install',
62
+ devCmd: 'pnpm run dev',
63
+ ciCmd: 'pnpm install --frozen-lockfile',
64
+ lockfile: 'pnpm-lock.yaml',
65
+ cacheName: 'pnpm',
66
+ setupStepYaml: PNPM_ACTION_SETUP_STEP,
67
+ };
68
+ }
69
+ return {
70
+ pm,
71
+ installCmd: 'npm install',
72
+ devCmd: 'npm run dev',
73
+ ciCmd: 'npm ci',
74
+ lockfile: 'package-lock.json',
75
+ cacheName: 'npm',
76
+ setupStepYaml: '',
77
+ };
78
+ }
79
+ //# sourceMappingURL=detect-package-manager.js.map
@@ -0,0 +1,337 @@
1
+ 'use strict';
2
+ // Development server for a scaffolded Antora site — ported from the
3
+ // monorepo's code/scripts/dev.mjs, trimmed to what a standalone site (no
4
+ // sibling ui-bundle package, ever) actually needs: this package builds and
5
+ // ships the server so a bugfix reaches already-scaffolded sites on their
6
+ // next `npm update @inditextech/docouture-cli`, rather than being frozen at
7
+ // whatever `docouture new` copied at scaffold time.
8
+ //
9
+ // Antora is a batch generator with no incremental mode, so "live reload"
10
+ // here means re-running the whole build on every change and reloading the
11
+ // browser once it lands. A failed rebuild leaves the previous output in
12
+ // place — the browser keeps showing the last version that worked rather
13
+ // than a half-written site.
14
+ import { createServer } from 'node:http';
15
+ import { execFile } from 'node:child_process';
16
+ import { readFile, stat, watch as watchDir } from 'node:fs/promises';
17
+ import { createReadStream, existsSync } from 'node:fs';
18
+ import { extname, isAbsolute, join, normalize, relative, resolve } from 'node:path';
19
+ import { readSiteUrl } from './playbook-yml.js';
20
+ import { ANTORA_LOG_LEVEL_ARGS, filterObservableAntoraLog } from './antora-log.js';
21
+ import { debugLog } from './debug-log.js';
22
+ const RELOAD_PATH = '/__dev/reload';
23
+ const CLIENT_PATH = '/__dev/client.js';
24
+ const DEBOUNCE_MS = 150;
25
+ const BUILD_TIMEOUT_MS = 120_000;
26
+ const CLIENT_SCRIPT = `// Injected by docouture dev. Not part of the built site.
27
+ const es = new EventSource(${JSON.stringify(RELOAD_PATH)})
28
+ es.addEventListener('message', () => location.reload())
29
+ // Chrome (and other browsers) can freeze a navigated-away-from page in the
30
+ // back/forward cache instead of tearing it down — keeping its EventSource
31
+ // open indefinitely. Without this, every navigation leaks one open SSE
32
+ // connection; after enough of them the browser's per-origin connection
33
+ // limit is exhausted and the current page's own requests stop completing.
34
+ // 'pagehide' fires on both a normal unload and a bfcache freeze, so this
35
+ // closes the connection either way.
36
+ window.addEventListener('pagehide', () => es.close())
37
+ `;
38
+ const CONTENT_TYPES = {
39
+ '.css': 'text/css; charset=utf-8',
40
+ '.html': 'text/html; charset=utf-8',
41
+ '.js': 'text/javascript; charset=utf-8',
42
+ '.json': 'application/json; charset=utf-8',
43
+ '.svg': 'image/svg+xml',
44
+ '.png': 'image/png',
45
+ '.jpg': 'image/jpeg',
46
+ '.gif': 'image/gif',
47
+ '.ico': 'image/x-icon',
48
+ '.woff': 'font/woff',
49
+ '.woff2': 'font/woff2',
50
+ '.ttf': 'font/ttf',
51
+ '.map': 'application/json; charset=utf-8',
52
+ '.xml': 'application/xml; charset=utf-8',
53
+ '.txt': 'text/plain; charset=utf-8',
54
+ };
55
+ const IGNORED = /(?:^|[\\/])(?:\.|.*~$)|\.swp$/;
56
+ function defaultRunBuild(siteRoot, onChildStart) {
57
+ // On Windows, a locally-installed bin is shimmed as `<name>.cmd`, not the
58
+ // bare POSIX shell script `execFile` would otherwise try (and fail) to
59
+ // run directly — see run-script.ts's own comment on the equivalent `npm`
60
+ // vs `npm.cmd` gotcha.
61
+ const antoraBinName = process.platform === 'win32' ? 'antora.cmd' : 'antora';
62
+ const antoraBin = resolve(siteRoot, 'node_modules', '.bin', antoraBinName);
63
+ return () => new Promise((resolvePromise) => {
64
+ // No --fetch here: that belongs to the one-off build only — on a
65
+ // watch loop it would re-fetch the content source on every keystroke.
66
+ // A timeout guards against a wedged/hung antora child process: with
67
+ // none, a single stuck build would permanently block every future
68
+ // rebuild for the rest of the dev session (drain()'s `running` flag
69
+ // never clears because runBuild() never resolves).
70
+ debugLog(`spawning: ${antoraBin} antora-playbook.local.yml ${ANTORA_LOG_LEVEL_ARGS.join(' ')}`);
71
+ const child = execFile(antoraBin, ['antora-playbook.local.yml', ...ANTORA_LOG_LEVEL_ARGS], { cwd: siteRoot, timeout: BUILD_TIMEOUT_MS }, (err, stdout, stderr) => {
72
+ if (err) {
73
+ // A failed rebuild gets its raw, unfiltered output — the whole
74
+ // story, not a filtered excerpt that might cut short exactly
75
+ // the line that explains the failure.
76
+ if (stdout)
77
+ process.stdout.write(stdout);
78
+ if (stderr)
79
+ process.stderr.write(stderr);
80
+ }
81
+ else {
82
+ // A successful rebuild would otherwise be completely silent —
83
+ // every docouture-* extension's own observability logs (Kroki's
84
+ // auto-start/render lifecycle, search-index's summary, ...)
85
+ // discarded along with Antora's own routine noise. See
86
+ // antora-log.ts's own header for why `--log-level=info` above
87
+ // is what makes those lines exist to filter in the first place.
88
+ const observable = filterObservableAntoraLog(`${stdout}\n${stderr}`);
89
+ if (observable)
90
+ process.stdout.write(observable + '\n');
91
+ }
92
+ resolvePromise(!err);
93
+ });
94
+ onChildStart?.(child);
95
+ });
96
+ }
97
+ async function readBasePath(siteRoot) {
98
+ let source;
99
+ try {
100
+ source = await readFile(join(siteRoot, 'antora-playbook.local.yml'), 'utf8');
101
+ }
102
+ catch {
103
+ return '';
104
+ }
105
+ const url = readSiteUrl(source);
106
+ if (!url || url === '/')
107
+ return '';
108
+ const path = url.startsWith('/') ? url : URL.parse(url)?.pathname;
109
+ if (!path || path === '/')
110
+ return '';
111
+ return path.replace(/\/+$/, '');
112
+ }
113
+ export async function startDevServer(options) {
114
+ const { siteRoot } = options;
115
+ const log = options.log ?? ((msg) => console.log(`dev ${msg}`));
116
+ const logError = options.logError ?? ((msg) => console.error(`dev ${msg}`));
117
+ // Only the default build path's child is trackable for a forced kill on
118
+ // shutdown (below) — a custom options.runBuild (tests; a future
119
+ // non-Antora build) owns its own process lifecycle, if it has one at all.
120
+ let activeBuildChild = null;
121
+ const runBuild = options.runBuild ?? defaultRunBuild(siteRoot, (child) => (activeBuildChild = child));
122
+ const root = resolve(siteRoot, 'build', 'site');
123
+ const basePath = await readBasePath(siteRoot);
124
+ async function resolveTarget(urlPath) {
125
+ const decoded = decodeURIComponent(urlPath.split('?')[0] ?? '');
126
+ const candidate = resolve(join(root, normalize(decoded)));
127
+ // Containment check against `root`, expressed as a relative-path test —
128
+ // the form static analysis (CodeQL's js/path-injection) recognises as a
129
+ // real sanitizer, not just `startsWith` on two strings. A `candidate`
130
+ // outside `root` resolves to a relative path that either escapes
131
+ // upwards (`..`) or is itself absolute (no common prefix at all).
132
+ const rel = relative(root, candidate);
133
+ if (rel.startsWith('..') || isAbsolute(rel))
134
+ return null;
135
+ try {
136
+ const info = await stat(candidate);
137
+ if (!info.isDirectory())
138
+ return candidate;
139
+ const index = join(candidate, 'index.html');
140
+ await stat(index);
141
+ return index;
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ const clients = new Set();
148
+ function notifyReload() {
149
+ for (const res of clients) {
150
+ try {
151
+ res.write('data: reload\n\n');
152
+ }
153
+ catch {
154
+ // Client's socket already gone (e.g. torn down mid-navigation) —
155
+ // drop it rather than let an unhandled write error crash the
156
+ // server; the 'error'/'close' listeners below also reap it.
157
+ clients.delete(res);
158
+ }
159
+ }
160
+ }
161
+ /**
162
+ * A path that isn't in the site. Antora only writes `404.html` at the
163
+ * site root when `site.url` is set (@antora/page-composer
164
+ * create404Page) — when it does, serve it (rewritten for reload, like
165
+ * any other HTML response) so the dev server's 404 matches the deployed
166
+ * one instead of a bare plain-text stand-in.
167
+ */
168
+ async function respondNotFound(res) {
169
+ try {
170
+ const html = await readFile(join(root, '404.html'), 'utf8');
171
+ const snippet = `<script src="${CLIENT_PATH}"></script>`;
172
+ const body = html.includes('</body>') ? html.replace('</body>', `${snippet}</body>`) : html + snippet;
173
+ res.writeHead(404, { 'content-type': CONTENT_TYPES['.html'], 'cache-control': 'no-store' });
174
+ res.end(body);
175
+ }
176
+ catch {
177
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
178
+ res.end('404 Not Found');
179
+ }
180
+ }
181
+ const server = createServer((req, res) => {
182
+ void (async () => {
183
+ const url = req.url ?? '/';
184
+ if (url === RELOAD_PATH) {
185
+ res.writeHead(200, {
186
+ 'content-type': 'text/event-stream; charset=utf-8',
187
+ 'cache-control': 'no-cache',
188
+ connection: 'keep-alive',
189
+ });
190
+ res.write(': connected\n\n');
191
+ clients.add(res);
192
+ req.on('close', () => clients.delete(res));
193
+ // A page navigation can drop the underlying socket before the
194
+ // 'close' event above fires; without an 'error' listener here
195
+ // Node's default behaviour for an unhandled stream error is to
196
+ // throw, taking the whole dev server down mid-navigation.
197
+ res.on('error', () => clients.delete(res));
198
+ return;
199
+ }
200
+ if (url === CLIENT_PATH) {
201
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' });
202
+ res.end(CLIENT_SCRIPT);
203
+ return;
204
+ }
205
+ let sitePath = url;
206
+ if (basePath) {
207
+ if (url === '/' || url === basePath) {
208
+ res.writeHead(302, { location: `${basePath}/` });
209
+ res.end();
210
+ return;
211
+ }
212
+ if (!url.startsWith(`${basePath}/`)) {
213
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
214
+ res.end(`404 Not Found — this site is served under ${basePath}/`);
215
+ return;
216
+ }
217
+ sitePath = url.slice(basePath.length);
218
+ }
219
+ const target = await resolveTarget(sitePath);
220
+ if (!target) {
221
+ await respondNotFound(res);
222
+ return;
223
+ }
224
+ const contentType = CONTENT_TYPES[extname(target)] ?? 'application/octet-stream';
225
+ if (contentType === CONTENT_TYPES['.html']) {
226
+ const html = await readFile(target, 'utf8');
227
+ const snippet = `<script src="${CLIENT_PATH}"></script>`;
228
+ const body = html.includes('</body>') ? html.replace('</body>', `${snippet}</body>`) : html + snippet;
229
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' });
230
+ res.end(body);
231
+ return;
232
+ }
233
+ res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' });
234
+ createReadStream(target).pipe(res);
235
+ })();
236
+ });
237
+ let running = false;
238
+ let queued = false;
239
+ let timer = null;
240
+ function schedule() {
241
+ queued = true;
242
+ if (timer)
243
+ clearTimeout(timer);
244
+ timer = setTimeout(() => void drain(), DEBOUNCE_MS);
245
+ }
246
+ async function drain() {
247
+ timer = null;
248
+ if (running || !queued)
249
+ return;
250
+ running = true;
251
+ try {
252
+ while (queued) {
253
+ queued = false;
254
+ const started = Date.now();
255
+ log('content changed, rebuilding site');
256
+ const ok = await runBuild();
257
+ if (ok) {
258
+ log(`rebuilt in ${((Date.now() - started) / 1000).toFixed(1)}s`);
259
+ notifyReload();
260
+ }
261
+ else {
262
+ logError('rebuild failed, serving the previous build');
263
+ }
264
+ }
265
+ }
266
+ catch (err) {
267
+ // A build that throws instead of resolving false must not leave
268
+ // `running` stuck true forever — that would silently disable every
269
+ // future rebuild for the rest of the dev session.
270
+ logError(`rebuild crashed, serving the previous build: ${err.message}`);
271
+ }
272
+ finally {
273
+ running = false;
274
+ }
275
+ }
276
+ const watchAbort = new AbortController();
277
+ async function watchPath(path, watchOptions = {}) {
278
+ try {
279
+ for await (const event of watchDir(path, {
280
+ recursive: watchOptions.recursive ?? true,
281
+ signal: watchAbort.signal,
282
+ })) {
283
+ if (event.filename && IGNORED.test(event.filename))
284
+ continue;
285
+ schedule();
286
+ }
287
+ }
288
+ catch (err) {
289
+ if (err.name !== 'AbortError') {
290
+ logError(`stopped watching ${path}: ${err.message}`);
291
+ }
292
+ }
293
+ }
294
+ if (!existsSync(root)) {
295
+ log('no build output yet, building the site');
296
+ if (!(await runBuild())) {
297
+ throw new Error('initial build failed');
298
+ }
299
+ }
300
+ await new Promise((resolvePromise, reject) => {
301
+ server.once('error', reject);
302
+ server.listen(options.port ?? 5000, () => {
303
+ server.off('error', reject);
304
+ resolvePromise();
305
+ });
306
+ });
307
+ const address = server.address();
308
+ const port = typeof address === 'object' && address ? address.port : (options.port ?? 5000);
309
+ log(`serving ${root}`);
310
+ log(` http://localhost:${port}${basePath}/`);
311
+ const watchers = [
312
+ watchPath(join(siteRoot, 'docs')),
313
+ watchPath(join(siteRoot, 'antora-playbook.local.yml'), { recursive: false }),
314
+ ];
315
+ void Promise.all(watchers);
316
+ return {
317
+ port,
318
+ basePath,
319
+ url: `http://localhost:${port}${basePath}/`,
320
+ async close() {
321
+ watchAbort.abort();
322
+ if (timer)
323
+ clearTimeout(timer);
324
+ // A rebuild that's still in flight when SIGINT/SIGTERM arrives would
325
+ // otherwise be left running as an orphan after the dev server itself
326
+ // has already torn down — kill it too, best-effort (it may already
327
+ // have exited on its own by the time close() runs).
328
+ if (activeBuildChild && activeBuildChild.exitCode === null && !activeBuildChild.killed) {
329
+ activeBuildChild.kill('SIGTERM');
330
+ }
331
+ for (const res of clients)
332
+ res.end();
333
+ await new Promise((resolvePromise) => server.close(() => resolvePromise()));
334
+ },
335
+ };
336
+ }
337
+ //# sourceMappingURL=dev-server.js.map