From 84a0011943a15a1b104b6ae011a71ea493097459 Mon Sep 17 00:00:00 2001 From: nijoe1 Date: Mon, 1 Dec 2025 16:55:33 +0200 Subject: [PATCH 1/4] feat: implements local development workflow with custom gitbook-cli wrapper - Replaces legacy gitbook-cli with a custom local wrapper - Automatically manages Node v10 via nvm for GitBook compatibility - Adds live reload/watch mode --- .gitignore | 4 + README.md | 42 +++ book.json | 3 + gitbook-plugins/.gitignore | 2 + gitbook-plugins/assets/embed.css | 38 +++ gitbook-plugins/assets/hints.css | 119 +++++++++ gitbook-plugins/assets/tabs.css | 42 +++ gitbook-plugins/assets/tabs.js | 34 +++ gitbook-plugins/package.json | 30 +++ gitbook-plugins/src/cli/builder.ts | 176 +++++++++++++ gitbook-plugins/src/cli/commands.ts | 110 ++++++++ gitbook-plugins/src/cli/constants.ts | 21 ++ gitbook-plugins/src/cli/gitbook.ts | 32 +++ gitbook-plugins/src/cli/index.ts | 63 +++++ gitbook-plugins/src/cli/log.ts | 79 ++++++ gitbook-plugins/src/cli/node-version.ts | 228 +++++++++++++++++ gitbook-plugins/src/cli/server.ts | 53 ++++ gitbook-plugins/src/cli/watch.ts | 327 ++++++++++++++++++++++++ gitbook-plugins/src/plugin/embed.ts | 49 ++++ gitbook-plugins/src/plugin/hints.ts | 23 ++ gitbook-plugins/src/plugin/index.ts | 87 +++++++ gitbook-plugins/src/plugin/tabs.ts | 19 ++ gitbook-plugins/tsconfig.cli.json | 16 ++ gitbook-plugins/tsconfig.plugin.json | 17 ++ package.json | 14 +- 25 files changed, 1626 insertions(+), 2 deletions(-) create mode 100644 book.json create mode 100644 gitbook-plugins/.gitignore create mode 100644 gitbook-plugins/assets/embed.css create mode 100644 gitbook-plugins/assets/hints.css create mode 100644 gitbook-plugins/assets/tabs.css create mode 100644 gitbook-plugins/assets/tabs.js create mode 100644 gitbook-plugins/package.json create mode 100644 gitbook-plugins/src/cli/builder.ts create mode 100644 gitbook-plugins/src/cli/commands.ts create mode 100644 gitbook-plugins/src/cli/constants.ts create mode 100644 gitbook-plugins/src/cli/gitbook.ts create mode 100644 gitbook-plugins/src/cli/index.ts create mode 100644 gitbook-plugins/src/cli/log.ts create mode 100644 gitbook-plugins/src/cli/node-version.ts create mode 100644 gitbook-plugins/src/cli/server.ts create mode 100644 gitbook-plugins/src/cli/watch.ts create mode 100644 gitbook-plugins/src/plugin/embed.ts create mode 100644 gitbook-plugins/src/plugin/hints.ts create mode 100644 gitbook-plugins/src/plugin/index.ts create mode 100644 gitbook-plugins/src/plugin/tabs.ts create mode 100644 gitbook-plugins/tsconfig.cli.json create mode 100644 gitbook-plugins/tsconfig.plugin.json diff --git a/.gitignore b/.gitignore index feb1e22a7..4b0272f91 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ Thumbs.db :Zone.Identifier **/:Zone.Identifier lychee* +_book/ +_book_temp/ +node_modules/ +package-lock.json \ No newline at end of file diff --git a/README.md b/README.md index 4e46324a1..f279da5eb 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - [About this repo](#about-this-repo) - [Contributing](#contributing) + - [Local Development](#local-development) - [Link checking](#link-checking) - [Issues](#issues) - [Backlog](#backlog) @@ -32,6 +33,47 @@ PRs also generate preview links so one can preview the site before merging. Per Want to help out? Pull requests (PRs) are always welcome! If you want to help out but aren't sure where to start, check out the [issues board](https://github.com/filecoin-project/filecoin-docs/issues). +### Local Development + +You can build and preview the documentation locally using the custom CLI wrapper. This setup automatically manages the legacy Node.js v10 environment required by GitBook. + +#### Prerequisites + +- [nvm](https://github.com/nvm-sh/nvm) (Node Version Manager) + + **macOS/Linux:** + + ```bash + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash + ``` + + **Windows:** Use [nvm-windows](https://github.com/coreybutler/nvm-windows) or WSL + +#### Quick Start + +1. **Setup**: Installs dependencies and prepares the environment. + + ```bash + npm run setup + ``` + +2. **Develop**: Builds and serves the site with live reload at http://localhost:4003. + ```bash + npm run dev + ``` + +#### Commands + +| Command | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------- | +| `npm run setup` | Installs dependencies and configures the legacy GitBook environment (runs automatically on first use) | +| `npm run dev` | Builds and serves the documentation with live reload (default port: 4003) | +| `npm run build` | Builds the static site to the `_book/` directory | +| `npm run preview` | Serves the existing `_book/` directory without rebuilding | +| `npm run stop` | Stops any running GitBook server instances | +| `npm run clean` | Removes build artifacts and dependencies | + + ### Link checking Links are checked using [lychee-action](https://github.com/lycheeverse/lychee-action) as configured by [check-external-links.yml](.github/workflows/check-external-links.yml). Working links are required before merging. If you have a link that should be excluded from checking: diff --git a/book.json b/book.json new file mode 100644 index 000000000..d1ad3dd73 --- /dev/null +++ b/book.json @@ -0,0 +1,3 @@ +{ + "plugins": ["local"] +} diff --git a/gitbook-plugins/.gitignore b/gitbook-plugins/.gitignore new file mode 100644 index 000000000..1eae0cf67 --- /dev/null +++ b/gitbook-plugins/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/gitbook-plugins/assets/embed.css b/gitbook-plugins/assets/embed.css new file mode 100644 index 000000000..c7ca7ffb2 --- /dev/null +++ b/gitbook-plugins/assets/embed.css @@ -0,0 +1,38 @@ +.embed-container { + margin: 1.5em 0; +} + +.embed-container.youtube { + position: relative; + padding-bottom: 56.25%; /* 16:9 aspect ratio */ + height: 0; + overflow: hidden; + max-width: 100%; +} + +.embed-container.youtube iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.embed-container.generic { + padding: 1em; + background: #f5f5f5; + border-radius: 4px; + border: 1px solid #e8e8e8; +} + +.embed-container.generic a { + word-break: break-all; +} + +.embed-caption { + margin-top: 0.5em; + font-size: 0.9em; + color: #666; + text-align: center; + font-style: italic; +} diff --git a/gitbook-plugins/assets/hints.css b/gitbook-plugins/assets/hints.css new file mode 100644 index 000000000..5b4a30e58 --- /dev/null +++ b/gitbook-plugins/assets/hints.css @@ -0,0 +1,119 @@ +/* Modern hint/callout styling */ +.hint { + display: flex; + margin: 1.5em 0; + padding: 1em 1.25em; + border-radius: 8px; + border-left: 4px solid; + background: linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.hint-icon { + flex-shrink: 0; + font-size: 1.4em; + line-height: 1; + margin-right: 1em; + margin-top: 0.1em; +} + +.hint-content { + flex: 1; + min-width: 0; +} + +.hint-title { + font-weight: 600; + font-size: 0.9em; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 0.5em; +} + +.hint-content p { + margin: 0; + line-height: 1.6; +} + +.hint-content p + p { + margin-top: 0.75em; +} + +/* Info - Blue */ +.hint-info { + border-left-color: #0969da; + background: linear-gradient(135deg, #f0f7ff 0%, #e8f2fc 100%); +} + +.hint-info .hint-icon { + color: #0969da; +} + +.hint-info .hint-title { + color: #0550ae; +} + +/* Warning - Yellow/Orange */ +.hint-warning { + border-left-color: #d4a72c; + background: linear-gradient(135deg, #fff8e6 0%, #fef3cd 100%); +} + +.hint-warning .hint-icon { + color: #d4a72c; +} + +.hint-warning .hint-title { + color: #9a6700; +} + +/* Danger - Red */ +.hint-danger { + border-left-color: #cf222e; + background: linear-gradient(135deg, #fff0f0 0%, #ffeaea 100%); +} + +.hint-danger .hint-icon { + color: #cf222e; +} + +.hint-danger .hint-title { + color: #a40e26; +} + +/* Success - Green */ +.hint-success { + border-left-color: #1a7f37; + background: linear-gradient(135deg, #f0fff4 0%, #dafbe1 100%); +} + +.hint-success .hint-icon { + color: #1a7f37; +} + +.hint-success .hint-title { + color: #116329; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .hint { + background: rgba(255, 255, 255, 0.05); + } + + .hint-info { + background: rgba(9, 105, 218, 0.1); + } + + .hint-warning { + background: rgba(212, 167, 44, 0.1); + } + + .hint-danger { + background: rgba(207, 34, 46, 0.1); + } + + .hint-success { + background: rgba(26, 127, 55, 0.1); + } +} diff --git a/gitbook-plugins/assets/tabs.css b/gitbook-plugins/assets/tabs.css new file mode 100644 index 000000000..e2809f1cc --- /dev/null +++ b/gitbook-plugins/assets/tabs.css @@ -0,0 +1,42 @@ +.tabs-container { + margin: 1em 0; + border: 1px solid #e8e8e8; + border-radius: 4px; +} + +.tabs-header { + display: flex; + flex-wrap: wrap; + background: #f5f5f5; + border-bottom: 1px solid #e8e8e8; +} + +.tabs-header .tab { + padding: 10px 20px; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: all 0.2s ease; +} + +.tabs-header .tab:hover { + background: #eaeaea; +} + +.tabs-header .tab.active { + background: #fff; + border-bottom: 2px solid #3498db; + font-weight: 600; +} + +.tabs-body { + padding: 15px; +} + +.tabs-body .tab-content { + display: none; +} + +.tabs-body .tab-content.active { + display: block; +} diff --git a/gitbook-plugins/assets/tabs.js b/gitbook-plugins/assets/tabs.js new file mode 100644 index 000000000..5b9e48234 --- /dev/null +++ b/gitbook-plugins/assets/tabs.js @@ -0,0 +1,34 @@ +require(["gitbook"], function (gitbook) { + var initTabs = function () { + var containers = document.querySelectorAll(".tabs-container"); + + containers.forEach(function (container) { + var headerTabs = container.querySelectorAll(".tabs-header .tab"); + var contentTabs = container.querySelectorAll(".tabs-body .tab-content"); + + headerTabs.forEach(function (tab) { + tab.addEventListener("click", function () { + var tabIndex = this.getAttribute("data-tab"); + + // Remove active from all tabs in this container + headerTabs.forEach(function (t) { + t.classList.remove("active"); + }); + contentTabs.forEach(function (c) { + c.classList.remove("active"); + }); + + // Add active to clicked tab + this.classList.add("active"); + container + .querySelector( + '.tabs-body .tab-content[data-tab="' + tabIndex + '"]' + ) + .classList.add("active"); + }); + }); + }); + }; + + gitbook.events.bind("page.change", initTabs); +}); diff --git a/gitbook-plugins/package.json b/gitbook-plugins/package.json new file mode 100644 index 000000000..2684e8bff --- /dev/null +++ b/gitbook-plugins/package.json @@ -0,0 +1,30 @@ +{ + "name": "gitbook-plugin-local", + "version": "1.0.0", + "description": "GitBook.com compatibility plugin for gitbook-cli (tabs, embed, hints)", + "main": "dist/plugin/index.js", + "bin": { + "gitbook-cli": "./dist/cli/index.js" + }, + "scripts": { + "build": "npm run build:cli && npm run build:plugin && npm run copy-assets && npm run postbuild", + "build:cli": "tsc -p tsconfig.cli.json", + "build:plugin": "tsc -p tsconfig.plugin.json", + "build:watch": "tsc -p tsconfig.cli.json --watch & tsc -p tsconfig.plugin.json --watch", + "copy-assets": "cp -r assets dist/plugin/", + "postbuild": "chmod +x dist/cli/index.js", + "prepublishOnly": "npm run build" + }, + "engines": { + "gitbook": ">=3.0.0" + }, + "dependencies": { + "chokidar": "^3.6.0", + "ora": "5.4.1", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^20.19.25", + "typescript": "^5.9.3" + } +} diff --git a/gitbook-plugins/src/cli/builder.ts b/gitbook-plugins/src/cli/builder.ts new file mode 100644 index 000000000..d35699b07 --- /dev/null +++ b/gitbook-plugins/src/cli/builder.ts @@ -0,0 +1,176 @@ +import { spawn, ChildProcess } from 'child_process'; +import { existsSync, rmSync, renameSync } from 'fs'; +import { log, spin } from './log'; +import { getRequiredNodeVersion, setupNodeVersion } from './node-version'; +import { CONFIG, getBookPath, getTempBookPath } from './constants'; + +type NvmPath = Awaited>['nvmPath']; +export type BuildState = 'idle' | 'building' | 'cancelling'; + +export class Builder { + private projectRoot: string; + private nvmPath: NvmPath; + private verbose: boolean; + private buildProcess: ChildProcess | null = null; + private buildProcessPid: number | null = null; + private state: BuildState = 'idle'; + + constructor(projectRoot: string, nvmPath: NvmPath, verbose: boolean = false) { + this.projectRoot = projectRoot; + this.nvmPath = nvmPath; + this.verbose = verbose; + } + + public get currentState(): BuildState { + return this.state; + } + + public async build(files: string[] = []): Promise { + this.state = 'building'; + const tempBookPath = getTempBookPath(this.projectRoot); + this.cleanupTemp(); + + const filesMsg = this.formatFiles(files); + spin.start(filesMsg ? `Rebuilding (${filesMsg})...` : 'Rebuilding...'); + + return new Promise((resolve) => { + const buildCmd = this.createNvmCommand(`gitbook build . "${tempBookPath}" 2>&1`); + + this.buildProcess = spawn(buildCmd, { + stdio: 'pipe', + shell: '/bin/bash', + cwd: this.projectRoot, + detached: true, + }); + + this.buildProcessPid = this.buildProcess.pid ? -this.buildProcess.pid : null; + + let output = ''; + this.buildProcess.stdout?.on('data', (data) => { output += data.toString(); }); + this.buildProcess.stderr?.on('data', (data) => { output += data.toString(); }); + + this.buildProcess.on('close', (code, signal) => { + const wasCancelled = this.state === 'cancelling' || signal != null; + this.cleanupProcess(); + + if (wasCancelled) { + this.cleanupTemp(); + resolve(false); + return; + } + + const hasSuccess = /generation finished with success/i.test(output); + if (code === 0 || hasSuccess) { + if (this.swapBuildOutput()) { + spin.succeed('Rebuild complete'); + resolve(true); + } else { + spin.fail('Build output missing'); + resolve(false); + } + } else { + spin.fail('Rebuild failed'); + this.logErrors(output); + this.cleanupTemp(); + resolve(false); + } + }); + + this.buildProcess.on('error', () => { + this.cleanupProcess(); + spin.fail('Build process error'); + this.cleanupTemp(); + resolve(false); + }); + }); + } + + public cancel(): void { + if (!this.buildProcess || this.state !== 'building') return; + this.state = 'cancelling'; + + if (this.buildProcessPid) { + try { + process.kill(this.buildProcessPid, 'SIGTERM'); + } catch { + // Process might already be dead + } + } else if (this.buildProcess.pid) { + this.buildProcess.kill('SIGTERM'); + } + + setTimeout(() => { + if (this.buildProcessPid) { + try { + process.kill(this.buildProcessPid, 'SIGKILL'); + } catch { + // Process might already be dead + } + } else if (this.buildProcess?.pid) { + this.buildProcess.kill('SIGKILL'); + } + }, 1000); + } + + private cleanupProcess(): void { + this.buildProcess = null; + this.buildProcessPid = null; + this.state = 'idle'; + } + + private createNvmCommand(command: string): string { + if (!this.nvmPath) return command; + const version = getRequiredNodeVersion(); + return `export NVM_DIR="${this.nvmPath.dir}" && . "${this.nvmPath.script}" && nvm use ${version} 2>/dev/null && ${command}`; + } + + private cleanupTemp(): void { + const tempPath = getTempBookPath(this.projectRoot); + try { + if (existsSync(tempPath)) { + rmSync(tempPath, { recursive: true, force: true }); + } + } catch { + // Ignore cleanup errors + } + } + + private swapBuildOutput(): boolean { + const bookPath = getBookPath(this.projectRoot); + const tempBookPath = getTempBookPath(this.projectRoot); + + try { + if (existsSync(bookPath)) { + rmSync(bookPath, { recursive: true, force: true }); + } + if (existsSync(tempBookPath)) { + renameSync(tempBookPath, bookPath); + return true; + } + return false; + } catch (err) { + log.error(`Failed to swap build output: ${err}`); + this.cleanupTemp(); + return false; + } + } + + private formatFiles(files: string[]): string { + if (files.length === 0) return ''; + if (files.length <= CONFIG.MAX_FILES_TO_SHOW) return files.join(', '); + return `${files.slice(0, CONFIG.MAX_FILES_TO_SHOW).join(', ')} +${files.length - CONFIG.MAX_FILES_TO_SHOW} more`; + } + + private logErrors(output: string): void { + const errorLines = output.split('\n').filter(line => + /Error:|error:|TypeError|ENOENT|Template render error/.test(line) + ); + if (errorLines.length > 0) { + log.error(errorLines.join('\n')); + } else { + log.error('See console for details'); + console.log(output.slice(-500)); + } + } +} + diff --git a/gitbook-plugins/src/cli/commands.ts b/gitbook-plugins/src/cli/commands.ts new file mode 100644 index 000000000..1c56863a1 --- /dev/null +++ b/gitbook-plugins/src/cli/commands.ts @@ -0,0 +1,110 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { resolve } from 'path'; +import { confirm, log, spin } from './log'; +import { checkGitbookInstalled, ensureGitbookReady } from './gitbook'; +import { getDefaultPort, killExistingServers } from './server'; +import { Builder } from './builder'; +import { watchAndServe } from './watch'; +import { getRequiredNodeVersion, runWithNodeVersion, setupNodeVersion } from './node-version'; + +export interface CommandOptions { + port?: number; + verbose?: boolean; +} + +function getProjectRoot(): string { + return resolve(__dirname, '..', '..', '..'); +} + +export async function build(): Promise { + const { nvmPath } = await ensureGitbookReady(); + const projectRoot = getProjectRoot(); + const builder = new Builder(projectRoot, nvmPath); + await builder.build(); +} + +export async function serve(options: CommandOptions = {}): Promise { + const port = options.port || getDefaultPort(); + const verbose = options.verbose || false; + const projectRoot = getProjectRoot(); + const { nvmPath } = await ensureGitbookReady(); + + killExistingServers(port); + await watchAndServe({ port, projectRoot, nvmPath, verbose }); +} + +export function preview(options: CommandOptions = {}): void { + const port = options.port || getDefaultPort(); + const projectRoot = getProjectRoot(); + const bookPath = resolve(projectRoot, '_book'); + + if (!existsSync(bookPath)) { + log.error('No _book directory found. Run "npm run build" first.'); + process.exit(1); + } + + killExistingServers(port); + log.info(`Serving static files from _book on port ${port}...`); + log.info('Press Ctrl+C to stop the server'); + console.log(''); + + try { + execSync(`npx --yes serve "${bookPath}" -l ${port} --no-request-logging`, { stdio: 'inherit' }); + } catch { + try { + execSync(`python3 -m http.server ${port} --directory "${bookPath}"`, { stdio: 'inherit' }); + } catch { + log.error('Could not start static server. Install serve: npm install -g serve'); + process.exit(1); + } + } +} + +export function stop(options: CommandOptions = {}): void { + const port = options.port || getDefaultPort(); + log.info('Stopping any running gitbook servers...'); + killExistingServers(port); + log.success('Servers stopped'); +} + +export async function setup(): Promise { + const nodeVersion = getRequiredNodeVersion(); + + // Step 1 & 2: Check/install nvm and Node version (reusing logic) + const { nvmPath } = await setupNodeVersion(); + + // Step 3: Check/install gitbook-cli + log.info('Checking for gitbook-cli...'); + const hasGitbook = checkGitbookInstalled(nvmPath); + + if (!hasGitbook) { + const shouldInstall = await confirm('gitbook-cli is not installed. Install it now?'); + if (!shouldInstall) { + log.info('Skipped. Install manually with:'); + console.log(` nvm use ${nodeVersion} && npm install -g gitbook-cli`); + process.exit(0); + } + + spin.start('Installing gitbook-cli...'); + try { + // Suppress npm warnings by redirecting stderr + runWithNodeVersion('npm install -g gitbook-cli 2>/dev/null', nvmPath, { silent: true }); + spin.succeed('gitbook-cli installed'); + } catch (err) { + spin.fail('Failed to install gitbook-cli'); + const msg = err instanceof Error ? err.message : String(err); + log.error(msg); + process.exit(1); + } + } else { + log.success('gitbook-cli is installed'); + } + + console.log(''); + log.success('Setup complete! You can now run:'); + console.log(''); + console.log(' npm run build # Build the docs'); + console.log(' npm run dev # Build and serve with live reload'); + console.log(''); +} diff --git a/gitbook-plugins/src/cli/constants.ts b/gitbook-plugins/src/cli/constants.ts new file mode 100644 index 000000000..1b6d89b4b --- /dev/null +++ b/gitbook-plugins/src/cli/constants.ts @@ -0,0 +1,21 @@ +import { join } from 'path'; + +export const REQUIRED_NODE_VERSION = '10'; +export const DEFAULT_PORT = 4003; +export const LIVERELOAD_PORT = 35729; +export const BOOK_DIR_NAME = '_book'; +export const TEMP_BOOK_DIR_NAME = '_book_temp'; + +export const CONFIG = { + DEBOUNCE_MS: 1000, + MAX_FILES_TO_SHOW: 3, +} as const; + +export function getBookPath(projectRoot: string): string { + return join(projectRoot, BOOK_DIR_NAME); +} + +export function getTempBookPath(projectRoot: string): string { + return join(projectRoot, TEMP_BOOK_DIR_NAME); +} + diff --git a/gitbook-plugins/src/cli/gitbook.ts b/gitbook-plugins/src/cli/gitbook.ts new file mode 100644 index 000000000..b3da7f480 --- /dev/null +++ b/gitbook-plugins/src/cli/gitbook.ts @@ -0,0 +1,32 @@ +import { log } from './log'; +import { getRequiredNodeVersion, runWithNodeVersion, setupNodeVersion } from './node-version'; + +type NodeSetupResult = Awaited>; + +export function checkGitbookInstalled(nvmPath: NodeSetupResult['nvmPath']): boolean { + try { + const output = runWithNodeVersion('which gitbook', nvmPath, { silent: true }); + + // If using NVM, verify the path matches the version + if (nvmPath) { + return output.includes(`v${getRequiredNodeVersion()}`); + } + + // If native (nvmPath is null), we already verified Node version in setupNodeVersion. + // So just existing is enough. + return !!output.trim(); + } catch { + return false; + } +} + +export async function ensureGitbookReady(): Promise { + const nodeSetup = await setupNodeVersion(); + + if (!checkGitbookInstalled(nodeSetup.nvmPath)) { + log.error('gitbook-cli is not installed. Run "npm run setup" first.'); + process.exit(1); + } + + return nodeSetup; +} diff --git a/gitbook-plugins/src/cli/index.ts b/gitbook-plugins/src/cli/index.ts new file mode 100644 index 000000000..b49a53f77 --- /dev/null +++ b/gitbook-plugins/src/cli/index.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env node + +import { build, preview, serve, setup, stop } from './commands'; +import { printBanner, printUsage } from './log'; +import { getDefaultPort } from './server'; + +function parseArgs(args: string[]): { command: string; port?: number; verbose: boolean } { + let command = 'build'; + let port: number | undefined; + let verbose = false; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--verbose' || arg === '-v') { + verbose = true; + } else if (arg === '--port' || arg === '-p') { + port = parseInt(args[++i], 10); + } else if (!arg.startsWith('-')) { + command = arg; + } + } + + return { command, port, verbose }; +} + +async function main(): Promise { + const { command, port, verbose } = parseArgs(process.argv.slice(2)); + + printBanner(); + + const options = { port, verbose }; + + try { + switch (command) { + case 'setup': + await setup(); + break; + case 'build': + await build(); + break; + case 'serve': + await serve(options); + break; + case 'preview': + preview(options); + break; + case 'stop': + stop(options); + break; + default: + printUsage(getDefaultPort()); + process.exit(1); + } + } catch (err) { + console.error('Fatal error:', err instanceof Error ? err.message : String(err)); + process.exit(1); + } +} + +main().catch((err) => { + console.error('Fatal error:', err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/gitbook-plugins/src/cli/log.ts b/gitbook-plugins/src/cli/log.ts new file mode 100644 index 000000000..03b6c50aa --- /dev/null +++ b/gitbook-plugins/src/cli/log.ts @@ -0,0 +1,79 @@ +import ora, { Ora } from 'ora'; +import pc from 'picocolors'; +import * as readline from 'readline'; + +let currentSpinner: Ora | null = null; + +export const log = { + info: (msg: string) => console.log(`${pc.blue('ℹ')} ${msg}`), + success: (msg: string) => console.log(`${pc.green('✓')} ${msg}`), + warn: (msg: string) => console.log(`${pc.yellow('⚠')} ${msg}`), + error: (msg: string) => console.log(`${pc.red('✗')} ${msg}`), +}; + +export const spin = { + start: (msg: string) => { + if (currentSpinner) { + currentSpinner.text = msg; + return currentSpinner; + } + currentSpinner = ora(msg).start(); + return currentSpinner; + }, + succeed: (msg?: string) => { + if (currentSpinner) { + currentSpinner.succeed(msg); + currentSpinner = null; + } + }, + fail: (msg?: string) => { + if (currentSpinner) { + currentSpinner.fail(msg); + currentSpinner = null; + } + }, + stop: () => { + if (currentSpinner) { + currentSpinner.stop(); + currentSpinner = null; + } + }, +}; + +export async function confirm(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(`${pc.yellow('?')} ${question} (Y/n) `, (answer) => { + rl.close(); + const normalized = answer.trim().toLowerCase(); + resolve(normalized === '' || normalized === 'y' || normalized === 'yes'); + }); + }); +} + +export function printBanner(): void { + console.log(''); + console.log(pc.cyan('========================================')); + console.log(pc.cyan(' Filecoin Docs - GitBook Build Script')); + console.log(pc.cyan('========================================')); + console.log(''); +} + +export function printUsage(defaultPort: number): void { + console.log('Usage: gitbook-cli [options]'); + console.log(''); + console.log('Commands:'); + console.log(` ${pc.green('setup')} - Install Node 10 and gitbook-cli via nvm`); + console.log(` ${pc.green('build')} - Build the gitbook (default)`); + console.log(` ${pc.green('serve')} - Build and serve with live reload`); + console.log(` ${pc.green('preview')} - Serve static _book (no rebuild)`); + console.log(` ${pc.green('stop')} - Stop any running servers`); + console.log(''); + console.log('Options:'); + console.log(` --port Port number (default: ${defaultPort})`); + console.log(' --verbose Show detailed output'); +} diff --git a/gitbook-plugins/src/cli/node-version.ts b/gitbook-plugins/src/cli/node-version.ts new file mode 100644 index 000000000..9ee2d9e6a --- /dev/null +++ b/gitbook-plugins/src/cli/node-version.ts @@ -0,0 +1,228 @@ +import { execSync, spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { log, spin, confirm } from './log'; + +export const REQUIRED_NODE_VERSION = '10'; + +export interface NvmPaths { + script: string; + dir: string; +} + +/** + * Finds the NVM installation path. + * Checks common locations: environment variable, home directory, and Homebrew paths. + */ +export function findNvmPath(): NvmPaths | null { + const homeDir = process.env.HOME || ''; + const nvmDir = process.env.NVM_DIR || join(homeDir, '.nvm'); + + const possiblePaths = [ + { dir: nvmDir, script: join(nvmDir, 'nvm.sh') }, + { dir: '/usr/local/opt/nvm', script: '/usr/local/opt/nvm/nvm.sh' }, + { dir: '/opt/homebrew/opt/nvm', script: '/opt/homebrew/opt/nvm/nvm.sh' }, + ]; + + // Try to find via brew if available + try { + const brewPrefix = execSync('brew --prefix nvm', { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim(); + if (brewPrefix) { + possiblePaths.push({ dir: brewPrefix, script: join(brewPrefix, 'nvm.sh') }); + } + } catch { + // Ignore if brew is not available + } + + for (const path of possiblePaths) { + if (existsSync(path.script)) { + return path; + } + } + return null; +} + +function getNodeMajorVersion(): number { + try { + const version = execSync('node -v', { encoding: 'utf-8' }).trim(); + return parseInt(version.replace('v', '').split('.')[0], 10); + } catch { + return 0; + } +} + +function getCurrentNodeVersion(): string { + try { + return execSync('node -v', { encoding: 'utf-8' }).trim(); + } catch { + return ''; + } +} + +/** + * Checks if a specific Node version is available in NVM. + * Runs `nvm ls ` and checks for the version string in output. + */ +function checkNodeVersionAvailable(nvmPath: NvmPaths): boolean { + try { + const checkCmd = ` + export NVM_DIR="${nvmPath.dir}" + . "${nvmPath.script}" + nvm ls ${REQUIRED_NODE_VERSION} 2>/dev/null | grep -q "v${REQUIRED_NODE_VERSION}" + `; + execSync(checkCmd, { stdio: 'pipe', shell: '/bin/bash' }); + return true; + } catch { + return false; + } +} + +/** + * Installs the required Node version using NVM. + */ +async function installNodeVersion(nvmPath: NvmPaths): Promise { + spin.start(`Installing Node.js v${REQUIRED_NODE_VERSION} via nvm...`); + try { + const installCmd = ` + export NVM_DIR="${nvmPath.dir}" + . "${nvmPath.script}" + nvm install ${REQUIRED_NODE_VERSION} 2>&1 + `; + execSync(installCmd, { stdio: 'pipe', shell: '/bin/bash' }); + spin.succeed(`Node.js v${REQUIRED_NODE_VERSION} installed successfully`); + return true; + } catch (err) { + spin.fail(`Failed to install Node.js v${REQUIRED_NODE_VERSION}`); + const errorMessage = err instanceof Error ? err.message : String(err); + if (errorMessage.includes('nvm: command not found')) { + log.error('Reason: nvm command not available in shell'); + } else if (errorMessage.includes('No such file')) { + log.error('Reason: nvm.sh script not found'); + } else if (errorMessage.includes('network')) { + log.error('Reason: Network error during download'); + } else { + log.error(`Reason: ${errorMessage}`); + } + return false; + } +} + +/** + * Ensures the required Node version is installed and set up. + * 1. Checks current Node version. + * 2. If mismatch, finds NVM. + * 3. Checks/Installs required Node version via NVM. + * 4. Caches the PATH environment variable for the required Node version to speed up future executions. + */ +export async function setupNodeVersion(): Promise<{ originalVersion: string; nvmPath: NvmPaths | null }> { + const originalVersion = getCurrentNodeVersion(); + const currentMajor = getNodeMajorVersion(); + + if (currentMajor === parseInt(REQUIRED_NODE_VERSION, 10)) { + log.success(`Node.js v${REQUIRED_NODE_VERSION} is already active`); + return { originalVersion, nvmPath: null }; + } + + log.warn(`Gitbook-cli requires Node.js v${REQUIRED_NODE_VERSION}.x (current: v${currentMajor})`); + + const nvmPath = findNvmPath(); + if (!nvmPath) { + log.error('nvm is not installed. Please install nvm first:'); + console.log(''); + console.log(' curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash'); + console.log(''); + console.log(`Then install Node.js v${REQUIRED_NODE_VERSION}:`); + console.log(''); + console.log(` nvm install ${REQUIRED_NODE_VERSION}`); + console.log(''); + process.exit(1); + } + + // Check if Node 10 is installed via nvm, if not prompt to install it + if (!checkNodeVersionAvailable(nvmPath)) { + log.warn(`Node.js v${REQUIRED_NODE_VERSION} is not installed.`); + + const shouldInstall = await confirm(`Install Node.js v${REQUIRED_NODE_VERSION} via nvm?`); + if (!shouldInstall) { + log.info('Installation cancelled. Please install manually:'); + console.log(''); + console.log(` nvm install ${REQUIRED_NODE_VERSION}`); + console.log(''); + process.exit(0); + } + + const installed = await installNodeVersion(nvmPath); + if (!installed) { + log.error('Please install manually:'); + console.log(''); + console.log(` nvm install ${REQUIRED_NODE_VERSION}`); + console.log(''); + process.exit(1); + } + } + + log.info(`Using nvm to switch to Node.js v${REQUIRED_NODE_VERSION}...`); + + return { originalVersion, nvmPath }; +} + +/** + * Creates the command prefix to run commands in the correct NVM context. + */ +export function getNvmCommandPrefix(nvmPath: NvmPaths | null): string { + if (!nvmPath) return ''; + return `export NVM_DIR="${nvmPath.dir}" && . "${nvmPath.script}" && nvm use ${REQUIRED_NODE_VERSION} 2>/dev/null &&`; +} + +/** + * Executes a command within the context of the required Node version. + * Explicitly sources NVM script on every call to ensure correct environment. + */ +export function runWithNodeVersion(command: string, nvmPath: NvmPaths | null, options?: { silent?: boolean }): string { + const stdio = options?.silent ? 'pipe' : 'inherit'; + + if (!nvmPath) { + const result = execSync(command, { stdio, encoding: 'utf-8', shell: '/bin/bash' }); + return result || ''; + } + + const prefix = getNvmCommandPrefix(nvmPath); + const nvmCommand = `${prefix} ${command}`; + + const result = execSync(nvmCommand, { stdio, encoding: 'utf-8', shell: '/bin/bash' }); + return result || ''; +} + +/** + * Spawns a child process within the context of the required Node version. + * Handles signal propagation (SIGINT, SIGTERM) to the child process. + */ +export function spawnWithNodeVersion(command: string, nvmPath: NvmPaths | null): void { + const nvmCommand = nvmPath + ? `export NVM_DIR="${nvmPath.dir}" && . "${nvmPath.script}" && nvm use ${REQUIRED_NODE_VERSION} && ${command}` + : command; + + const child = spawn(nvmCommand, { + stdio: 'inherit', + shell: '/bin/bash', + }); + + child.on('error', (err) => { + console.error('Failed to start process:', err); + process.exit(1); + }); + + const killChild = (signal: NodeJS.Signals) => { + if (child.pid) child.kill(signal); + }; + + process.on('SIGINT', () => killChild('SIGINT')); + process.on('SIGTERM', () => killChild('SIGTERM')); +} + +export function getRequiredNodeVersion(): string { + return REQUIRED_NODE_VERSION; +} diff --git a/gitbook-plugins/src/cli/server.ts b/gitbook-plugins/src/cli/server.ts new file mode 100644 index 000000000..4ed71a735 --- /dev/null +++ b/gitbook-plugins/src/cli/server.ts @@ -0,0 +1,53 @@ +import { execSync, spawn, ChildProcess } from 'child_process'; +import { log } from './log'; +import { DEFAULT_PORT, LIVERELOAD_PORT } from './constants'; + +function killProcessOnPort(port: number): void { + try { + // Find PID(s) using the port + const pids = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + + if (pids) { + log.warn(`Killing existing server on port ${port} (PIDs: ${pids.replace(/\n/g, ', ')})...`); + // Kill the processes + execSync(`kill -9 ${pids.split('\n').join(' ')}`, { stdio: 'ignore' }); + } + } catch { + // No process on port or lsof failed, which is expected/fine + } +} + +export function killExistingServers(port: number = DEFAULT_PORT): void { + killProcessOnPort(LIVERELOAD_PORT); + killProcessOnPort(port); +} + +export function getDefaultPort(): number { + return DEFAULT_PORT; +} + +export class ServerManager { + private port: number; + private process: ChildProcess | null = null; + private bookPath: string; + + constructor(port: number, bookPath: string) { + this.port = port; + this.bookPath = bookPath; + } + + public start(): void { + if (this.process) return; + const serveCmd = `npx --yes serve "${this.bookPath}" -l ${this.port} --no-request-logging`; + this.process = spawn(serveCmd, { stdio: 'inherit', shell: '/bin/bash' }); + this.process.on('error', (err) => log.error(`Server error: ${err.message}`)); + this.process.on('close', () => { this.process = null; }); + } + + public stop(): void { + if (this.process) { + this.process.kill(); + this.process = null; + } + } +} diff --git a/gitbook-plugins/src/cli/watch.ts b/gitbook-plugins/src/cli/watch.ts new file mode 100644 index 000000000..69ca328cc --- /dev/null +++ b/gitbook-plugins/src/cli/watch.ts @@ -0,0 +1,327 @@ +import { spawn, ChildProcess } from 'child_process'; +import { watch, FSWatcher } from 'chokidar'; +import { existsSync, rmSync, renameSync } from 'fs'; +import { resolve } from 'path'; +import { log, spin, confirm } from './log'; +import { getRequiredNodeVersion, NvmPaths } from './node-version'; +import { killExistingServers } from './server'; + +const CONFIG = { + DEBOUNCE_MS: 1000, + MAX_FILES_TO_SHOW: 3, +} as const; + +type BuildState = 'idle' | 'building' | 'cancelling'; + +export interface WatchOptions { + port: number; + projectRoot: string; + nvmPath: NvmPaths | null; + verbose?: boolean; +} + +class BuildManager { + private options: WatchOptions; + private bookPath: string; + private tempBookPath: string; + + private serverProcess: ChildProcess | null = null; + private buildProcess: ChildProcess | null = null; + private buildProcessPid: number | null = null; + private watcher: FSWatcher | null = null; + + private buildState: BuildState = 'idle'; + private debounceTimer: NodeJS.Timeout | null = null; + private cancelTimer: NodeJS.Timeout | null = null; + + private pendingFiles: Set = new Set(); + private currentBuildFiles: string[] = []; + + constructor(options: WatchOptions) { + this.options = options; + this.bookPath = resolve(options.projectRoot, '_book'); + this.tempBookPath = resolve(options.projectRoot, '_book_temp'); + } + + public async start(): Promise { + this.cleanupTemp(); + + if (existsSync(this.bookPath)) { + log.info('Using existing _book...'); + const shouldRebuild = await confirm('Rebuild before starting?'); + if (shouldRebuild) { + this.runInitialBuild(); + } else { + this.startServer(); + this.setupWatcher(); + } + } else { + this.runInitialBuild(); + } + + this.setupSignalHandlers(); + } + + private createNvmCommand(command: string): string { + const { nvmPath } = this.options; + if (!nvmPath) return command; + const version = getRequiredNodeVersion(); + return `export NVM_DIR="${nvmPath.dir}" && . "${nvmPath.script}" && nvm use ${version} 2>/dev/null && ${command}`; + } + + private cleanupTemp(): void { + try { + if (existsSync(this.tempBookPath)) { + rmSync(this.tempBookPath, { recursive: true, force: true }); + } + } catch { + // Ignore cleanup errors + } + } + + private formatFiles(files: string[] | Set): string { + const arr = Array.isArray(files) ? files : Array.from(files); + if (arr.length === 0) return ''; + if (arr.length <= CONFIG.MAX_FILES_TO_SHOW) return arr.join(', '); + return `${arr.slice(0, CONFIG.MAX_FILES_TO_SHOW).join(', ')} +${arr.length - CONFIG.MAX_FILES_TO_SHOW} more`; + } + + private startServer(): void { + if (this.serverProcess) return; + const serveCmd = `npx --yes serve "${this.bookPath}" -l ${this.options.port} --no-request-logging`; + this.serverProcess = spawn(serveCmd, { stdio: 'inherit', shell: '/bin/bash' }); + this.serverProcess.on('error', (err) => log.error(`Server error: ${err.message}`)); + this.serverProcess.on('close', () => { this.serverProcess = null; }); + log.info(`Server: http://localhost:${this.options.port}`); + } + + private swapBuildOutput(): boolean { + try { + if (existsSync(this.bookPath)) { + rmSync(this.bookPath, { recursive: true, force: true }); + } + if (existsSync(this.tempBookPath)) { + renameSync(this.tempBookPath, this.bookPath); + return true; + } + return false; + } catch (err) { + log.error(`Failed to swap build output: ${err}`); + this.cleanupTemp(); + return false; + } + } + + private startBuild(): void { + this.currentBuildFiles = Array.from(this.pendingFiles); + this.pendingFiles.clear(); + this.buildState = 'building'; + this.cleanupTemp(); + + const filesMsg = this.formatFiles(this.currentBuildFiles); + spin.start(filesMsg ? `Rebuilding (${filesMsg})...` : 'Rebuilding...'); + + const buildCmd = this.createNvmCommand(`gitbook build . "${this.tempBookPath}" 2>&1`); + + this.buildProcess = spawn(buildCmd, { + stdio: 'pipe', + shell: '/bin/bash', + cwd: this.options.projectRoot, + detached: true, + }); + + this.buildProcessPid = this.buildProcess.pid ? -this.buildProcess.pid : null; + + let output = ''; + this.buildProcess.stdout?.on('data', (data) => { output += data.toString(); }); + this.buildProcess.stderr?.on('data', (data) => { output += data.toString(); }); + + this.buildProcess.on('close', (code, signal) => this.handleBuildClose(code, signal, output)); + this.buildProcess.on('error', () => this.handleBuildError()); + } + + private handleBuildClose(code: number | null, signal: NodeJS.Signals | null, output: string): void { + this.buildProcess = null; + this.buildProcessPid = null; + const wasCancelled = this.buildState === 'cancelling' || signal != null; + this.buildState = 'idle'; + + if (wasCancelled) { + this.cleanupTemp(); + this.currentBuildFiles.forEach(f => this.pendingFiles.add(f)); + if (this.pendingFiles.size > 0) this.startBuild(); + return; + } + + const hasSuccess = /generation finished with success/i.test(output); + if ((code === 0 || hasSuccess) && this.swapBuildOutput()) { + spin.succeed('Rebuild complete'); + } else { + spin.fail('Rebuild failed'); + this.logBuildErrors(output); + this.cleanupTemp(); + } + + if (this.pendingFiles.size > 0) { + this.startBuild(); + } + } + + private handleBuildError(): void { + this.buildProcess = null; + this.buildProcessPid = null; + this.buildState = 'idle'; + spin.fail('Build process error'); + this.cleanupTemp(); + } + + private logBuildErrors(output: string): void { + const errorLines = output.split('\n').filter(line => + /Error:|error:|TypeError|ENOENT|Template render error/.test(line) + ); + if (errorLines.length > 0) { + log.error(errorLines.join('\n')); + } else { + log.error('See console for details'); + console.log(output.slice(-500)); + } + } + + private cancelBuild(): void { + if (!this.buildProcess || this.buildState !== 'building') return; + this.buildState = 'cancelling'; + + if (this.buildProcessPid) { + try { process.kill(this.buildProcessPid, 'SIGTERM'); } catch { } + } else if (this.buildProcess.pid) { + this.buildProcess.kill('SIGTERM'); + } + + setTimeout(() => { + if (this.buildProcessPid) { + try { process.kill(this.buildProcessPid, 'SIGKILL'); } catch { } + } else if (this.buildProcess?.pid) { + this.buildProcess.kill('SIGKILL'); + } + }, 1000); + } + + private onFileChange(file: string): void { + const isNewFile = !this.pendingFiles.has(file); + this.pendingFiles.add(file); + + if (this.buildState === 'building' || this.buildState === 'cancelling') { + if (isNewFile && this.options.verbose) { + log.info(`Queued: ${file}`); + } + if (this.cancelTimer) clearTimeout(this.cancelTimer); + this.cancelTimer = setTimeout(() => { + if (this.buildState === 'building') this.cancelBuild(); + }, 500); + return; + } + + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => { + this.debounceTimer = null; + if (this.buildState === 'idle' && this.pendingFiles.size > 0) { + this.startBuild(); + } + }, CONFIG.DEBOUNCE_MS); + } + + private setupWatcher(): void { + log.info('Watching...'); + this.watcher = watch( + [ + resolve(this.options.projectRoot, '**/*.md'), + resolve(this.options.projectRoot, 'book.json'), + resolve(this.options.projectRoot, 'SUMMARY.md'), + ], + { + ignored: [ + resolve(this.options.projectRoot, '_book/**'), + resolve(this.options.projectRoot, '_book_temp/**'), + resolve(this.options.projectRoot, 'node_modules/**'), + resolve(this.options.projectRoot, 'gitbook-plugins/**'), + ], + ignoreInitial: true, + persistent: true, + awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }, + } + ); + + const handleChange = (filePath: string) => { + this.onFileChange(filePath.replace(this.options.projectRoot + '/', '')); + }; + + this.watcher.on('change', handleChange); + this.watcher.on('add', handleChange); + this.watcher.on('unlink', handleChange); + } + + private runInitialBuild(): void { + spin.start('Building...'); + this.buildState = 'building'; + + const buildCmd = this.createNvmCommand(`gitbook build . "${this.tempBookPath}" 2>&1`); + + this.buildProcess = spawn(buildCmd, { + stdio: 'pipe', + shell: '/bin/bash', + cwd: this.options.projectRoot, + detached: true, + }); + + this.buildProcessPid = this.buildProcess.pid ? -this.buildProcess.pid : null; + let output = ''; + + this.buildProcess.stdout?.on('data', (data) => { output += data.toString(); }); + this.buildProcess.stderr?.on('data', (data) => { output += data.toString(); }); + + this.buildProcess.on('close', (code) => { + this.buildProcess = null; + this.buildProcessPid = null; + this.buildState = 'idle'; + + const hasSuccess = /generation finished with success/i.test(output); + if ((code === 0 || hasSuccess) && this.swapBuildOutput()) { + spin.succeed('Build complete'); + this.startServer(); + this.setupWatcher(); + } else { + spin.fail('Initial build failed'); + this.logBuildErrors(output); + this.cleanupTemp(); + process.exit(1); + } + }); + } + + private setupSignalHandlers(): void { + const cleanup = () => { + if (this.debounceTimer) clearTimeout(this.debounceTimer); + if (this.cancelTimer) clearTimeout(this.cancelTimer); + this.watcher?.close(); + + if (this.buildProcessPid) { + try { process.kill(this.buildProcessPid, 'SIGKILL'); } catch { } + } else if (this.buildProcess) { + this.buildProcess.kill('SIGKILL'); + } + + if (this.serverProcess) this.serverProcess.kill(); + killExistingServers(this.options.port); + this.cleanupTemp(); + process.exit(0); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + } +} + +export async function watchAndServe(options: WatchOptions): Promise { + const manager = new BuildManager(options); + await manager.start(); +} diff --git a/gitbook-plugins/src/plugin/embed.ts b/gitbook-plugins/src/plugin/embed.ts new file mode 100644 index 000000000..d3114919a --- /dev/null +++ b/gitbook-plugins/src/plugin/embed.ts @@ -0,0 +1,49 @@ +function getYouTubeId(url: string): string | null { + if (!url) return null; + // Support various YouTube URL formats + const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/; + const match = url.match(regExp); + return match && match[2] && match[2].length === 11 ? match[2] : null; +} + +export function createEmbedHtml(url: string, caption: string): string { + if (!url) { + return '

Missing embed URL

'; + } + + const youtubeId = getYouTubeId(url); + + if (youtubeId) { + const html = + '
' + + `' + + (caption ? `

${caption}

` : '') + + '
'; + return html; + } + + return ( + '
' + + `${url}` + + (caption ? `

${caption}

` : '') + + '
' + ); +} + +export function processEmbeds(content: string): string { + // Handle embeds with endembed block + content = content.replace( + /\{%\s*embed\s+url="([^"]+)"\s*%\}([\s\S]*?)\{%\s*endembed\s*%\}/g, + (_match, url: string, caption: string) => createEmbedHtml(url, caption.trim()) + ); + + // Handle self-closing embeds + content = content.replace( + /\{%\s*embed\s+url="([^"]+)"\s*%\}(?!\s*[\s\S]*?\{%\s*endembed)/g, + (_match, url: string) => createEmbedHtml(url, '') + ); + + return content; +} diff --git a/gitbook-plugins/src/plugin/hints.ts b/gitbook-plugins/src/plugin/hints.ts new file mode 100644 index 000000000..31aaeba43 --- /dev/null +++ b/gitbook-plugins/src/plugin/hints.ts @@ -0,0 +1,23 @@ +export type HintStyle = 'info' | 'warning' | 'danger' | 'success'; + +export const HINT_ICONS: Record = { + info: 'ⓘ', // ⓘ info circle + warning: '⚠', // ⚠ warning triangle + danger: '⚠', // ⚠ warning triangle (red) + success: '✓', // ✓ checkmark +}; + +export const HINT_TITLES: Record = { + info: 'Info', + warning: 'Warning', + danger: 'Danger', + success: 'Success', +}; + +export function getHintIcon(style: string): string { + return HINT_ICONS[style as HintStyle] || HINT_ICONS.info; +} + +export function getHintTitle(style: string): string { + return HINT_TITLES[style as HintStyle] || HINT_TITLES.info; +} diff --git a/gitbook-plugins/src/plugin/index.ts b/gitbook-plugins/src/plugin/index.ts new file mode 100644 index 000000000..c9c446991 --- /dev/null +++ b/gitbook-plugins/src/plugin/index.ts @@ -0,0 +1,87 @@ +import { createTab, createTabBody } from './tabs'; +import { processEmbeds } from './embed'; +import { getHintIcon, getHintTitle } from './hints'; + +interface Block { + name: string; + kwargs?: { title?: string; style?: string }; + body?: string; +} + +interface ParentBlock { + blocks?: Block[]; +} + +interface Page { + content: string; +} + +interface Book { + renderBlock(type: string, content: string): Promise; +} + +interface BlockContext { + book: Book; +} + +/** + * GitBook Plugin Definition + * Exports the plugin configuration required by GitBook. + * - assets: Path to static assets + * - hooks: Lifecycle hooks (e.g. page:before) + * - blocks: Custom blocks (tabs, hints) + */ +module.exports = { + book: { + assets: './assets', + css: ['tabs.css', 'embed.css', 'hints.css'], + js: ['tabs.js'], + }, + + hooks: { + 'page:before': function (page: Page): Page { + page.content = processEmbeds(page.content); + return page; + }, + }, + + blocks: { + tabs: { + blocks: ['tab', 'endtab'], + process: async function (this: BlockContext, parentBlock: ParentBlock): Promise { + const blocks = (parentBlock.blocks || []).filter((block) => block.name === 'tab'); + + const tabsHeader = blocks.map((block, i) => createTab(block, i, i === 0)).join(''); + const tabsContentPromises = blocks.map((block, i) => createTabBody(this.book, block, i, i === 0)); + const tabsContent = (await Promise.all(tabsContentPromises)).join(''); + + return ` +
+
${tabsHeader}
+
${tabsContent}
+
+ `.trim(); + }, + }, + + hint: { + blocks: ['endhint'], + process: async function (this: BlockContext, block: Block): Promise { + const style = (block.kwargs && block.kwargs.style) || 'info'; + const icon = getHintIcon(style); + const title = getHintTitle(style); + const renderedBody = await this.book.renderBlock('markdown', block.body || ''); + + return ` +
+
${icon}
+
+
${title}
+ ${renderedBody} +
+
+ `.trim(); + }, + }, + }, +}; diff --git a/gitbook-plugins/src/plugin/tabs.ts b/gitbook-plugins/src/plugin/tabs.ts new file mode 100644 index 000000000..6ef8f5a99 --- /dev/null +++ b/gitbook-plugins/src/plugin/tabs.ts @@ -0,0 +1,19 @@ +interface TabBlock { + kwargs?: { title?: string }; + body?: string; +} + +interface Book { + renderBlock(type: string, content: string): Promise; +} + +export function createTab(block: TabBlock, index: number, isActive: boolean): string { + const title = (block.kwargs && block.kwargs.title) || 'Tab ' + (index + 1); + return `
${title}
`; +} + +export async function createTabBody(book: Book, block: TabBlock, index: number, isActive: boolean): Promise { + const body = block.body || ''; + const rendered = body ? await book.renderBlock('markdown', body) : ''; + return `
${rendered}
`; +} diff --git a/gitbook-plugins/tsconfig.cli.json b/gitbook-plugins/tsconfig.cli.json new file mode 100644 index 000000000..ab780d41d --- /dev/null +++ b/gitbook-plugins/tsconfig.cli.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist/cli", + "rootDir": "./src/cli", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true + }, + "include": ["src/cli/**/*"], + "exclude": ["node_modules"] +} diff --git a/gitbook-plugins/tsconfig.plugin.json b/gitbook-plugins/tsconfig.plugin.json new file mode 100644 index 000000000..740fb2679 --- /dev/null +++ b/gitbook-plugins/tsconfig.plugin.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2015", + "module": "commonjs", + "lib": ["ES2015"], + "types": ["node"], + "outDir": "./dist/plugin", + "rootDir": "./src/plugin", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true + }, + "include": ["src/plugin/**/*"], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json index 6faa6b3f6..31b24f1f7 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,13 @@ "main": "update-versions.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "update-versions": "node update-versions.js" + "setup": "npm run build:plugin && npm install && gitbook-cli setup", + "build": "gitbook-cli build", + "dev": "gitbook-cli serve", + "preview": "gitbook-cli preview", + "stop": "gitbook-cli stop", + "build:plugin": "cd gitbook-plugins && npm install && npm run build", + "clean": "rm -rf _book _book_temp node_modules package-lock.json gitbook-plugins/node_modules gitbook-plugins/dist gitbook-plugins/package-lock.json" }, "repository": { "type": "git", @@ -17,5 +23,9 @@ "bugs": { "url": "https://github.com/filecoin-project/filecoin-docs/issues" }, - "homepage": "https://github.com/filecoin-project/filecoin-docs#readme" + "homepage": "https://github.com/filecoin-project/filecoin-docs#readme", + "devDependencies": { + "@types/node": "^24.10.1", + "gitbook-plugin-local": "file:./gitbook-plugins" + } } From 8d8848aa30ce39fbfb7715795145ce0bb3759f5d Mon Sep 17 00:00:00 2001 From: nijoe1 Date: Mon, 1 Dec 2025 16:55:44 +0200 Subject: [PATCH 2/4] chore: fixes incompatible gitbook-cli tuple text format --- networks/local-testnet/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/networks/local-testnet/README.md b/networks/local-testnet/README.md index d3a0bed72..7fbdf6617 100644 --- a/networks/local-testnet/README.md +++ b/networks/local-testnet/README.md @@ -215,8 +215,8 @@ Before we can build the Lotus binaries, there’s some setup we need to do. We This will output something like:\\ ```plaintext - sector-id: {{1000 1} 5}, piece info: {2048 baga6ea4seaqf7ovs6euxa4ktencg2gza7lua32l2ugqu76uqgvnjocek6gtoufi} - 2023-01-31T10:49:46.562-0400 WARN preseal seed/seed.go:175 PreCommitOutput: {{1000 1} 5} bagboea4b5abcamxkzmzcciabqqk3xuuvj3k23nfuojboopyw3kg2mblhj6mzipii baga6ea4seaqf7ovs6euxa4ktencg2gza7lua32l2ugqu76uqgvnjocek6gtoufi + sector-id: ({1000 1} 5), piece info: {2048 baga6ea4seaqf7ovs6euxa4ktencg2gza7lua32l2ugqu76uqgvnjocek6gtoufi} + 2023-01-31T10:49:46.562-0400 WARN preseal seed/seed.go:175 PreCommitOutput: ({1000 1} 5) bagboea4b5abcamxkzmzcciabqqk3xuuvj3k23nfuojboopyw3kg2mblhj6mzipii baga6ea4seaqf7ovs6euxa4ktencg2gza7lua32l2ugqu76uqgvnjocek6gtoufi 2023-01-31T10:49:46.562-0400 WARN preseal seed/seed.go:100 PeerID not specified, generating dummy ... From 3495ec018d379b3e8c711b1a36cd3ed6a4d80481 Mon Sep 17 00:00:00 2001 From: nijoe1 Date: Tue, 2 Dec 2025 21:49:52 +0200 Subject: [PATCH 3/4] fix: lint checker error --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f279da5eb..5faa919cb 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ You can build and preview the documentation locally using the custom CLI wrapper npm run setup ``` -2. **Develop**: Builds and serves the site with live reload at http://localhost:4003. +2. **Develop**: Builds and serves the site with live reload. ```bash npm run dev ``` From f6ecc6183f456580625d5c56b3722cb980c01b74 Mon Sep 17 00:00:00 2001 From: nijoe1 Date: Fri, 5 Dec 2025 16:27:07 +0200 Subject: [PATCH 4/4] refactor(cli): simplify and consolidate CLI codebase - Remove dead code (ServerManager, spawnWithNodeVersion, cancel methods) - Consolidate constants and utilities to single sources - Add --verbose flag support across all commands - Use local gitbook-cli install in .gitbook/cli/ --- .gitignore | 3 +- book.json | 1 + gitbook-plugins/package.json | 2 +- gitbook-plugins/src/cli/builder.ts | 173 +++--------- gitbook-plugins/src/cli/commands.ts | 123 ++++---- gitbook-plugins/src/cli/constants.ts | 25 +- gitbook-plugins/src/cli/gitbook.ts | 40 +-- gitbook-plugins/src/cli/index.ts | 68 ++--- gitbook-plugins/src/cli/log.ts | 66 ++--- gitbook-plugins/src/cli/node-version.ts | 179 ++---------- gitbook-plugins/src/cli/server.ts | 45 +-- gitbook-plugins/src/cli/watch.ts | 357 ++++++++++-------------- gitbook-plugins/tsconfig.cli.json | 4 +- package.json | 5 +- 14 files changed, 369 insertions(+), 722 deletions(-) diff --git a/.gitignore b/.gitignore index 4b0272f91..fb2ff2865 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ lychee* _book/ _book_temp/ node_modules/ -package-lock.json \ No newline at end of file +package-lock.json +.gitbook/cli/ \ No newline at end of file diff --git a/book.json b/book.json index d1ad3dd73..eef56a246 100644 --- a/book.json +++ b/book.json @@ -1,3 +1,4 @@ { + "gitbook": "3.2.3", "plugins": ["local"] } diff --git a/gitbook-plugins/package.json b/gitbook-plugins/package.json index 2684e8bff..2e567f068 100644 --- a/gitbook-plugins/package.json +++ b/gitbook-plugins/package.json @@ -25,6 +25,6 @@ }, "devDependencies": { "@types/node": "^20.19.25", - "typescript": "^5.9.3" + "typescript": "^4.9.5" } } diff --git a/gitbook-plugins/src/cli/builder.ts b/gitbook-plugins/src/cli/builder.ts index d35699b07..3ace9cf7b 100644 --- a/gitbook-plugins/src/cli/builder.ts +++ b/gitbook-plugins/src/cli/builder.ts @@ -1,156 +1,76 @@ -import { spawn, ChildProcess } from 'child_process'; -import { existsSync, rmSync, renameSync } from 'fs'; +import { spawn } from 'child_process'; +import { existsSync, renameSync } from 'fs'; import { log, spin } from './log'; -import { getRequiredNodeVersion, setupNodeVersion } from './node-version'; -import { CONFIG, getBookPath, getTempBookPath } from './constants'; +import { setupNodeVersion, createNvmCommand } from './node-version'; +import { getGitbookBin } from './gitbook'; +import { CONFIG, getBookPath, getTempBookPath, rmRecursive } from './constants'; type NvmPath = Awaited>['nvmPath']; -export type BuildState = 'idle' | 'building' | 'cancelling'; export class Builder { - private projectRoot: string; - private nvmPath: NvmPath; - private verbose: boolean; - private buildProcess: ChildProcess | null = null; - private buildProcessPid: number | null = null; - private state: BuildState = 'idle'; + constructor( + private projectRoot: string, + private nvmPath: NvmPath, + private verbose = false + ) {} - constructor(projectRoot: string, nvmPath: NvmPath, verbose: boolean = false) { - this.projectRoot = projectRoot; - this.nvmPath = nvmPath; - this.verbose = verbose; - } - - public get currentState(): BuildState { - return this.state; - } + async build(files: string[] = []): Promise { + const tempPath = getTempBookPath(this.projectRoot); + const bookPath = getBookPath(this.projectRoot); + this.cleanup(tempPath); - public async build(files: string[] = []): Promise { - this.state = 'building'; - const tempBookPath = getTempBookPath(this.projectRoot); - this.cleanupTemp(); + log.debug(`Build output: ${bookPath}`, this.verbose); + log.debug(`Temp output: ${tempPath}`, this.verbose); + const isRebuild = files.length > 0; const filesMsg = this.formatFiles(files); - spin.start(filesMsg ? `Rebuilding (${filesMsg})...` : 'Rebuilding...'); + spin.start(isRebuild ? `Rebuilding (${filesMsg})...` : 'Building...'); return new Promise((resolve) => { - const buildCmd = this.createNvmCommand(`gitbook build . "${tempBookPath}" 2>&1`); - - this.buildProcess = spawn(buildCmd, { - stdio: 'pipe', - shell: '/bin/bash', - cwd: this.projectRoot, - detached: true, - }); - - this.buildProcessPid = this.buildProcess.pid ? -this.buildProcess.pid : null; + const cmd = createNvmCommand(`"${getGitbookBin()}" build . "${tempPath}" 2>&1`, this.nvmPath); + log.debug(`Build command: ${cmd}`, this.verbose); + const proc = spawn(cmd, { stdio: 'pipe', shell: '/bin/bash', cwd: this.projectRoot, detached: true }); let output = ''; - this.buildProcess.stdout?.on('data', (data) => { output += data.toString(); }); - this.buildProcess.stderr?.on('data', (data) => { output += data.toString(); }); - this.buildProcess.on('close', (code, signal) => { - const wasCancelled = this.state === 'cancelling' || signal != null; - this.cleanupProcess(); + proc.stdout?.on('data', (d) => { output += d; }); + proc.stderr?.on('data', (d) => { output += d; }); - if (wasCancelled) { - this.cleanupTemp(); - resolve(false); - return; - } + proc.on('close', (code) => { + if (this.verbose && output) console.log(output); - const hasSuccess = /generation finished with success/i.test(output); - if (code === 0 || hasSuccess) { - if (this.swapBuildOutput()) { - spin.succeed('Rebuild complete'); - resolve(true); - } else { - spin.fail('Build output missing'); - resolve(false); - } + const success = code === 0 || /generation finished with success/i.test(output); + if (success && this.swap(tempPath, bookPath)) { + spin.succeed(isRebuild ? 'Rebuild complete' : 'Build complete'); + resolve(true); } else { - spin.fail('Rebuild failed'); - this.logErrors(output); - this.cleanupTemp(); + spin.fail(isRebuild ? 'Rebuild failed' : 'Build failed'); + if (output) console.log(output); + this.cleanup(tempPath); resolve(false); } }); - this.buildProcess.on('error', () => { - this.cleanupProcess(); + proc.on('error', () => { spin.fail('Build process error'); - this.cleanupTemp(); + this.cleanup(tempPath); resolve(false); }); }); } - public cancel(): void { - if (!this.buildProcess || this.state !== 'building') return; - this.state = 'cancelling'; - - if (this.buildProcessPid) { - try { - process.kill(this.buildProcessPid, 'SIGTERM'); - } catch { - // Process might already be dead - } - } else if (this.buildProcess.pid) { - this.buildProcess.kill('SIGTERM'); - } - - setTimeout(() => { - if (this.buildProcessPid) { - try { - process.kill(this.buildProcessPid, 'SIGKILL'); - } catch { - // Process might already be dead - } - } else if (this.buildProcess?.pid) { - this.buildProcess.kill('SIGKILL'); - } - }, 1000); + private cleanup(path: string) { + try { if (existsSync(path)) rmRecursive(path); } catch { /* ignore */ } } - private cleanupProcess(): void { - this.buildProcess = null; - this.buildProcessPid = null; - this.state = 'idle'; - } - - private createNvmCommand(command: string): string { - if (!this.nvmPath) return command; - const version = getRequiredNodeVersion(); - return `export NVM_DIR="${this.nvmPath.dir}" && . "${this.nvmPath.script}" && nvm use ${version} 2>/dev/null && ${command}`; - } - - private cleanupTemp(): void { - const tempPath = getTempBookPath(this.projectRoot); - try { - if (existsSync(tempPath)) { - rmSync(tempPath, { recursive: true, force: true }); - } - } catch { - // Ignore cleanup errors - } - } - - private swapBuildOutput(): boolean { - const bookPath = getBookPath(this.projectRoot); - const tempBookPath = getTempBookPath(this.projectRoot); - + private swap(tempPath: string, bookPath: string): boolean { try { - if (existsSync(bookPath)) { - rmSync(bookPath, { recursive: true, force: true }); - } - if (existsSync(tempBookPath)) { - renameSync(tempBookPath, bookPath); - return true; - } + if (existsSync(bookPath)) rmRecursive(bookPath); + if (existsSync(tempPath)) { renameSync(tempPath, bookPath); return true; } return false; } catch (err) { log.error(`Failed to swap build output: ${err}`); - this.cleanupTemp(); + this.cleanup(tempPath); return false; } } @@ -160,17 +80,4 @@ export class Builder { if (files.length <= CONFIG.MAX_FILES_TO_SHOW) return files.join(', '); return `${files.slice(0, CONFIG.MAX_FILES_TO_SHOW).join(', ')} +${files.length - CONFIG.MAX_FILES_TO_SHOW} more`; } - - private logErrors(output: string): void { - const errorLines = output.split('\n').filter(line => - /Error:|error:|TypeError|ENOENT|Template render error/.test(line) - ); - if (errorLines.length > 0) { - log.error(errorLines.join('\n')); - } else { - log.error('See console for details'); - console.log(output.slice(-500)); - } - } } - diff --git a/gitbook-plugins/src/cli/commands.ts b/gitbook-plugins/src/cli/commands.ts index 1c56863a1..e98c3bd9e 100644 --- a/gitbook-plugins/src/cli/commands.ts +++ b/gitbook-plugins/src/cli/commands.ts @@ -2,109 +2,120 @@ import { execSync } from 'child_process'; import { existsSync } from 'fs'; import { resolve } from 'path'; import { confirm, log, spin } from './log'; -import { checkGitbookInstalled, ensureGitbookReady } from './gitbook'; +import { checkGitbookInstalled, ensureGitbookReady, getGitbookBin, installGitbook } from './gitbook'; import { getDefaultPort, killExistingServers } from './server'; import { Builder } from './builder'; import { watchAndServe } from './watch'; -import { getRequiredNodeVersion, runWithNodeVersion, setupNodeVersion } from './node-version'; +import { runWithNodeVersion, setupNodeVersion } from './node-version'; +import { REQUIRED_GITBOOK_VERSION, SERVE_PATH_PREFIX } from './constants'; export interface CommandOptions { port?: number; verbose?: boolean; } -function getProjectRoot(): string { - return resolve(__dirname, '..', '..', '..'); -} +const getRoot = () => resolve(__dirname, '..', '..', '..'); -export async function build(): Promise { +export async function build(opts: CommandOptions = {}) { + const verbose = opts.verbose ?? false; const { nvmPath } = await ensureGitbookReady(); - const projectRoot = getProjectRoot(); - const builder = new Builder(projectRoot, nvmPath); - await builder.build(); + const root = getRoot(); + log.debug(`Project: ${root}`, verbose); + await new Builder(root, nvmPath, verbose).build(); } -export async function serve(options: CommandOptions = {}): Promise { - const port = options.port || getDefaultPort(); - const verbose = options.verbose || false; - const projectRoot = getProjectRoot(); +export async function serve(opts: CommandOptions = {}) { + const port = opts.port ?? getDefaultPort(); + const verbose = opts.verbose ?? false; + const root = getRoot(); + log.debug(`Serving on ${port} from ${root}`, verbose); const { nvmPath } = await ensureGitbookReady(); - killExistingServers(port); - await watchAndServe({ port, projectRoot, nvmPath, verbose }); + await watchAndServe({ port, projectRoot: root, nvmPath, verbose }); } -export function preview(options: CommandOptions = {}): void { - const port = options.port || getDefaultPort(); - const projectRoot = getProjectRoot(); - const bookPath = resolve(projectRoot, '_book'); +export function preview(opts: CommandOptions = {}) { + const port = opts.port ?? getDefaultPort(); + const verbose = opts.verbose ?? false; + const bookPath = resolve(getRoot(), '_book'); + log.debug(`Preview: ${bookPath} on ${port}`, verbose); if (!existsSync(bookPath)) { - log.error('No _book directory found. Run "npm run build" first.'); + log.error('No _book directory. Run "npm run build" first.'); process.exit(1); } killExistingServers(port); - log.info(`Serving static files from _book on port ${port}...`); - log.info('Press Ctrl+C to stop the server'); + log.info(`Serving _book on port ${port}...`); + log.info('Ctrl+C to stop'); console.log(''); + const quiet = verbose ? '' : ' --no-request-logging 2>/dev/null'; try { - execSync(`npx --yes serve "${bookPath}" -l ${port} --no-request-logging`, { stdio: 'inherit' }); + execSync(`${SERVE_PATH_PREFIX} npx --yes serve@14 "${bookPath}" -l ${port}${quiet}`, { stdio: 'inherit' }); } catch { try { execSync(`python3 -m http.server ${port} --directory "${bookPath}"`, { stdio: 'inherit' }); } catch { - log.error('Could not start static server. Install serve: npm install -g serve'); + log.error('Could not start server. Install serve: npm install -g serve'); process.exit(1); } } } -export function stop(options: CommandOptions = {}): void { - const port = options.port || getDefaultPort(); - log.info('Stopping any running gitbook servers...'); +export function stop(opts: CommandOptions = {}) { + const port = opts.port ?? getDefaultPort(); + log.debug(`Stop port: ${port}`, opts.verbose); + log.info('Stopping servers...'); killExistingServers(port); - log.success('Servers stopped'); + log.success('Stopped'); } -export async function setup(): Promise { - const nodeVersion = getRequiredNodeVersion(); - - // Step 1 & 2: Check/install nvm and Node version (reusing logic) +export async function setup(opts: CommandOptions = {}) { + const verbose = opts.verbose ?? false; const { nvmPath } = await setupNodeVersion(); - // Step 3: Check/install gitbook-cli - log.info('Checking for gitbook-cli...'); - const hasGitbook = checkGitbookInstalled(nvmPath); - - if (!hasGitbook) { - const shouldInstall = await confirm('gitbook-cli is not installed. Install it now?'); - if (!shouldInstall) { - log.info('Skipped. Install manually with:'); - console.log(` nvm use ${nodeVersion} && npm install -g gitbook-cli`); + log.info('Checking gitbook-cli...'); + if (!checkGitbookInstalled()) { + if (!await confirm('gitbook-cli not installed. Install now?')) { + log.info('Run "npm run setup" when ready.'); process.exit(0); } - spin.start('Installing gitbook-cli...'); - try { - // Suppress npm warnings by redirecting stderr - runWithNodeVersion('npm install -g gitbook-cli 2>/dev/null', nvmPath, { silent: true }); - spin.succeed('gitbook-cli installed'); - } catch (err) { - spin.fail('Failed to install gitbook-cli'); - const msg = err instanceof Error ? err.message : String(err); - log.error(msg); + if (!await installGitbook(nvmPath)) { + spin.fail('Install failed'); process.exit(1); } + spin.succeed('gitbook-cli installed'); } else { - log.success('gitbook-cli is installed'); + log.success('gitbook-cli installed'); + } + + const bin = getGitbookBin(); + log.debug(`Binary: ${bin}`, verbose); + const quiet = verbose ? '' : ' 2>/dev/null'; + + let installed = false; + try { + const out = runWithNodeVersion(`"${bin}" ls${quiet}`, nvmPath, { silent: true, verbose }); + log.debug(`ls: ${out}`, verbose); + installed = out.includes(REQUIRED_GITBOOK_VERSION); + } catch { /* assume not installed */ } + + if (installed) { + log.success(`GitBook v${REQUIRED_GITBOOK_VERSION} installed`); + } else { + spin.start(`Fetching GitBook v${REQUIRED_GITBOOK_VERSION}...`); + try { + runWithNodeVersion(`"${bin}" fetch ${REQUIRED_GITBOOK_VERSION}${quiet}`, nvmPath, { silent: true, verbose }); + spin.succeed(`GitBook v${REQUIRED_GITBOOK_VERSION} installed`); + } catch { + spin.fail(`Failed to fetch GitBook v${REQUIRED_GITBOOK_VERSION}`); + process.exit(1); + } } console.log(''); - log.success('Setup complete! You can now run:'); - console.log(''); - console.log(' npm run build # Build the docs'); - console.log(' npm run dev # Build and serve with live reload'); - console.log(''); + log.success('Setup complete! Run:'); + console.log('\n npm run build # Build docs\n npm run dev # Dev server\n'); } diff --git a/gitbook-plugins/src/cli/constants.ts b/gitbook-plugins/src/cli/constants.ts index 1b6d89b4b..e24e381d7 100644 --- a/gitbook-plugins/src/cli/constants.ts +++ b/gitbook-plugins/src/cli/constants.ts @@ -1,21 +1,28 @@ +import { execSync } from 'child_process'; import { join } from 'path'; +// Version requirements export const REQUIRED_NODE_VERSION = '10'; +export const REQUIRED_GITBOOK_VERSION = '3.2.3'; + +// Server ports export const DEFAULT_PORT = 4003; export const LIVERELOAD_PORT = 35729; -export const BOOK_DIR_NAME = '_book'; -export const TEMP_BOOK_DIR_NAME = '_book_temp'; +// PATH prefix to use modern Node for serve (not nvm Node 10) +export const SERVE_PATH_PREFIX = 'PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"'; + +// Watch mode config export const CONFIG = { DEBOUNCE_MS: 1000, MAX_FILES_TO_SHOW: 3, } as const; -export function getBookPath(projectRoot: string): string { - return join(projectRoot, BOOK_DIR_NAME); -} - -export function getTempBookPath(projectRoot: string): string { - return join(projectRoot, TEMP_BOOK_DIR_NAME); -} +// Path helpers +export const getBookPath = (root: string): string => join(root, '_book'); +export const getTempBookPath = (root: string): string => join(root, '_book_temp'); +// Node 10 compatible rm -rf +export const rmRecursive = (path: string): void => { + execSync(`rm -rf "${path}"`, { stdio: 'pipe' }); +}; diff --git a/gitbook-plugins/src/cli/gitbook.ts b/gitbook-plugins/src/cli/gitbook.ts index b3da7f480..c3ee22188 100644 --- a/gitbook-plugins/src/cli/gitbook.ts +++ b/gitbook-plugins/src/cli/gitbook.ts @@ -1,32 +1,32 @@ +import { existsSync } from 'fs'; +import { resolve } from 'path'; import { log } from './log'; -import { getRequiredNodeVersion, runWithNodeVersion, setupNodeVersion } from './node-version'; +import { runWithNodeVersion, setupNodeVersion } from './node-version'; -type NodeSetupResult = Awaited>; +type NvmPath = Awaited>['nvmPath']; -export function checkGitbookInstalled(nvmPath: NodeSetupResult['nvmPath']): boolean { - try { - const output = runWithNodeVersion('which gitbook', nvmPath, { silent: true }); - - // If using NVM, verify the path matches the version - if (nvmPath) { - return output.includes(`v${getRequiredNodeVersion()}`); - } +const getGitbookDir = () => resolve(__dirname, '..', '..', '..', '.gitbook', 'cli'); + +export const getGitbookBin = () => resolve(getGitbookDir(), 'node_modules', '.bin', 'gitbook'); + +export const checkGitbookInstalled = () => existsSync(getGitbookBin()); - // If native (nvmPath is null), we already verified Node version in setupNodeVersion. - // So just existing is enough. - return !!output.trim(); - } catch { +export async function installGitbook(nvmPath: NvmPath): Promise { + const dir = getGitbookDir(); + try { + runWithNodeVersion(`mkdir -p "${dir}" && cd "${dir}" && npm init -y && npm install gitbook-cli@2.3.2`, nvmPath, { silent: false }); + return checkGitbookInstalled(); + } catch (err) { + log.error(`Install error: ${err}`); return false; } } -export async function ensureGitbookReady(): Promise { - const nodeSetup = await setupNodeVersion(); - - if (!checkGitbookInstalled(nodeSetup.nvmPath)) { +export async function ensureGitbookReady(): Promise<{ originalVersion: string; nvmPath: NvmPath }> { + const result = await setupNodeVersion(); + if (!checkGitbookInstalled()) { log.error('gitbook-cli is not installed. Run "npm run setup" first.'); process.exit(1); } - - return nodeSetup; + return result; } diff --git a/gitbook-plugins/src/cli/index.ts b/gitbook-plugins/src/cli/index.ts index b49a53f77..e6ff4db33 100644 --- a/gitbook-plugins/src/cli/index.ts +++ b/gitbook-plugins/src/cli/index.ts @@ -4,60 +4,30 @@ import { build, preview, serve, setup, stop } from './commands'; import { printBanner, printUsage } from './log'; import { getDefaultPort } from './server'; -function parseArgs(args: string[]): { command: string; port?: number; verbose: boolean } { - let command = 'build'; - let port: number | undefined; - let verbose = false; - +const parseArgs = (args: string[]) => { + let command = 'build', port: number | undefined, verbose = false; for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--verbose' || arg === '-v') { - verbose = true; - } else if (arg === '--port' || arg === '-p') { - port = parseInt(args[++i], 10); - } else if (!arg.startsWith('-')) { - command = arg; - } + const a = args[i]; + if (a === '--verbose' || a === '-v') verbose = true; + else if (a === '--port' || a === '-p') port = parseInt(args[++i], 10); + else if (!a.startsWith('-')) command = a; } - return { command, port, verbose }; -} +}; -async function main(): Promise { +const main = async () => { const { command, port, verbose } = parseArgs(process.argv.slice(2)); - printBanner(); - - const options = { port, verbose }; - - try { - switch (command) { - case 'setup': - await setup(); - break; - case 'build': - await build(); - break; - case 'serve': - await serve(options); - break; - case 'preview': - preview(options); - break; - case 'stop': - stop(options); - break; - default: - printUsage(getDefaultPort()); - process.exit(1); - } - } catch (err) { - console.error('Fatal error:', err instanceof Error ? err.message : String(err)); - process.exit(1); + const opts = { port, verbose }; + + switch (command) { + case 'setup': await setup(opts); break; + case 'build': await build(opts); break; + case 'serve': await serve(opts); break; + case 'preview': preview(opts); break; + case 'stop': stop(opts); break; + default: printUsage(getDefaultPort()); process.exit(1); } -} +}; -main().catch((err) => { - console.error('Fatal error:', err instanceof Error ? err.message : String(err)); - process.exit(1); -}); +main().catch((e) => { console.error('Fatal:', e instanceof Error ? e.message : e); process.exit(1); }); diff --git a/gitbook-plugins/src/cli/log.ts b/gitbook-plugins/src/cli/log.ts index 03b6c50aa..1e8d1721c 100644 --- a/gitbook-plugins/src/cli/log.ts +++ b/gitbook-plugins/src/cli/log.ts @@ -2,78 +2,52 @@ import ora, { Ora } from 'ora'; import pc from 'picocolors'; import * as readline from 'readline'; -let currentSpinner: Ora | null = null; +let spinner: Ora | null = null; export const log = { info: (msg: string) => console.log(`${pc.blue('ℹ')} ${msg}`), success: (msg: string) => console.log(`${pc.green('✓')} ${msg}`), warn: (msg: string) => console.log(`${pc.yellow('⚠')} ${msg}`), error: (msg: string) => console.log(`${pc.red('✗')} ${msg}`), + debug: (msg: string, verbose?: boolean) => verbose && console.log(`${pc.gray('DEBUG')} ${msg}`), }; export const spin = { start: (msg: string) => { - if (currentSpinner) { - currentSpinner.text = msg; - return currentSpinner; - } - currentSpinner = ora(msg).start(); - return currentSpinner; - }, - succeed: (msg?: string) => { - if (currentSpinner) { - currentSpinner.succeed(msg); - currentSpinner = null; - } - }, - fail: (msg?: string) => { - if (currentSpinner) { - currentSpinner.fail(msg); - currentSpinner = null; - } - }, - stop: () => { - if (currentSpinner) { - currentSpinner.stop(); - currentSpinner = null; - } + if (spinner) { spinner.text = msg; return spinner; } + spinner = ora(msg).start(); + return spinner; }, + succeed: (msg?: string) => { spinner?.succeed(msg); spinner = null; }, + fail: (msg?: string) => { spinner?.fail(msg); spinner = null; }, }; -export async function confirm(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(`${pc.yellow('?')} ${question} (Y/n) `, (answer) => { - rl.close(); - const normalized = answer.trim().toLowerCase(); - resolve(normalized === '' || normalized === 'y' || normalized === 'yes'); - }); +export const confirm = (question: string): Promise => new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(`${pc.yellow('?')} ${question} (Y/n) `, (answer) => { + rl.close(); + const a = answer.trim().toLowerCase(); + resolve(a === '' || a === 'y' || a === 'yes'); }); -} +}); -export function printBanner(): void { +export const printBanner = () => { console.log(''); console.log(pc.cyan('========================================')); console.log(pc.cyan(' Filecoin Docs - GitBook Build Script')); console.log(pc.cyan('========================================')); console.log(''); -} +}; -export function printUsage(defaultPort: number): void { - console.log('Usage: gitbook-cli [options]'); - console.log(''); +export const printUsage = (defaultPort: number) => { + console.log('Usage: gitbook-cli [options]\n'); console.log('Commands:'); console.log(` ${pc.green('setup')} - Install Node 10 and gitbook-cli via nvm`); console.log(` ${pc.green('build')} - Build the gitbook (default)`); console.log(` ${pc.green('serve')} - Build and serve with live reload`); console.log(` ${pc.green('preview')} - Serve static _book (no rebuild)`); - console.log(` ${pc.green('stop')} - Stop any running servers`); - console.log(''); + console.log(` ${pc.green('stop')} - Stop any running servers\n`); console.log('Options:'); console.log(` --port Port number (default: ${defaultPort})`); console.log(' --verbose Show detailed output'); -} +}; diff --git a/gitbook-plugins/src/cli/node-version.ts b/gitbook-plugins/src/cli/node-version.ts index 9ee2d9e6a..35a94a918 100644 --- a/gitbook-plugins/src/cli/node-version.ts +++ b/gitbook-plugins/src/cli/node-version.ts @@ -1,124 +1,69 @@ -import { execSync, spawn } from 'child_process'; +import { execSync } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; import { log, spin, confirm } from './log'; - -export const REQUIRED_NODE_VERSION = '10'; +import { REQUIRED_NODE_VERSION } from './constants'; export interface NvmPaths { script: string; dir: string; } -/** - * Finds the NVM installation path. - * Checks common locations: environment variable, home directory, and Homebrew paths. - */ -export function findNvmPath(): NvmPaths | null { +function findNvmPath(): NvmPaths | null { const homeDir = process.env.HOME || ''; const nvmDir = process.env.NVM_DIR || join(homeDir, '.nvm'); - const possiblePaths = [ + const paths = [ { dir: nvmDir, script: join(nvmDir, 'nvm.sh') }, { dir: '/usr/local/opt/nvm', script: '/usr/local/opt/nvm/nvm.sh' }, { dir: '/opt/homebrew/opt/nvm', script: '/opt/homebrew/opt/nvm/nvm.sh' }, ]; - // Try to find via brew if available + // Try brew prefix try { - const brewPrefix = execSync('brew --prefix nvm', { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'] - }).trim(); - if (brewPrefix) { - possiblePaths.push({ dir: brewPrefix, script: join(brewPrefix, 'nvm.sh') }); - } - } catch { - // Ignore if brew is not available - } + const brewPrefix = execSync('brew --prefix nvm', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + if (brewPrefix) paths.push({ dir: brewPrefix, script: join(brewPrefix, 'nvm.sh') }); + } catch { /* brew not available */ } - for (const path of possiblePaths) { - if (existsSync(path.script)) { - return path; - } - } - return null; + return paths.find(p => existsSync(p.script)) || null; } function getNodeMajorVersion(): number { try { - const version = execSync('node -v', { encoding: 'utf-8' }).trim(); - return parseInt(version.replace('v', '').split('.')[0], 10); + return parseInt(execSync('node -v', { encoding: 'utf-8' }).trim().replace('v', '').split('.')[0], 10); } catch { return 0; } } -function getCurrentNodeVersion(): string { - try { - return execSync('node -v', { encoding: 'utf-8' }).trim(); - } catch { - return ''; - } -} - -/** - * Checks if a specific Node version is available in NVM. - * Runs `nvm ls ` and checks for the version string in output. - */ function checkNodeVersionAvailable(nvmPath: NvmPaths): boolean { try { - const checkCmd = ` - export NVM_DIR="${nvmPath.dir}" - . "${nvmPath.script}" - nvm ls ${REQUIRED_NODE_VERSION} 2>/dev/null | grep -q "v${REQUIRED_NODE_VERSION}" - `; - execSync(checkCmd, { stdio: 'pipe', shell: '/bin/bash' }); + execSync(`export NVM_DIR="${nvmPath.dir}" && . "${nvmPath.script}" && nvm ls ${REQUIRED_NODE_VERSION} 2>/dev/null | grep -q "v${REQUIRED_NODE_VERSION}"`, { stdio: 'pipe', shell: '/bin/bash' }); return true; } catch { return false; } } -/** - * Installs the required Node version using NVM. - */ async function installNodeVersion(nvmPath: NvmPaths): Promise { spin.start(`Installing Node.js v${REQUIRED_NODE_VERSION} via nvm...`); try { - const installCmd = ` - export NVM_DIR="${nvmPath.dir}" - . "${nvmPath.script}" - nvm install ${REQUIRED_NODE_VERSION} 2>&1 - `; - execSync(installCmd, { stdio: 'pipe', shell: '/bin/bash' }); - spin.succeed(`Node.js v${REQUIRED_NODE_VERSION} installed successfully`); + execSync(`export NVM_DIR="${nvmPath.dir}" && . "${nvmPath.script}" && nvm install ${REQUIRED_NODE_VERSION} 2>&1`, { stdio: 'pipe', shell: '/bin/bash' }); + spin.succeed(`Node.js v${REQUIRED_NODE_VERSION} installed`); return true; } catch (err) { spin.fail(`Failed to install Node.js v${REQUIRED_NODE_VERSION}`); - const errorMessage = err instanceof Error ? err.message : String(err); - if (errorMessage.includes('nvm: command not found')) { - log.error('Reason: nvm command not available in shell'); - } else if (errorMessage.includes('No such file')) { - log.error('Reason: nvm.sh script not found'); - } else if (errorMessage.includes('network')) { - log.error('Reason: Network error during download'); - } else { - log.error(`Reason: ${errorMessage}`); - } + log.error(err instanceof Error ? err.message : String(err)); return false; } } -/** - * Ensures the required Node version is installed and set up. - * 1. Checks current Node version. - * 2. If mismatch, finds NVM. - * 3. Checks/Installs required Node version via NVM. - * 4. Caches the PATH environment variable for the required Node version to speed up future executions. - */ +function getNvmPrefix(nvmPath: NvmPaths): string { + return `unset npm_config_prefix && export NVM_DIR="${nvmPath.dir}" && . "${nvmPath.script}" && nvm use ${REQUIRED_NODE_VERSION} 2>/dev/null &&`; +} + export async function setupNodeVersion(): Promise<{ originalVersion: string; nvmPath: NvmPaths | null }> { - const originalVersion = getCurrentNodeVersion(); + const originalVersion = execSync('node -v', { encoding: 'utf-8' }).trim(); const currentMajor = getNodeMajorVersion(); if (currentMajor === parseInt(REQUIRED_NODE_VERSION, 10)) { @@ -131,98 +76,36 @@ export async function setupNodeVersion(): Promise<{ originalVersion: string; nvm const nvmPath = findNvmPath(); if (!nvmPath) { log.error('nvm is not installed. Please install nvm first:'); - console.log(''); - console.log(' curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash'); - console.log(''); - console.log(`Then install Node.js v${REQUIRED_NODE_VERSION}:`); - console.log(''); - console.log(` nvm install ${REQUIRED_NODE_VERSION}`); - console.log(''); + console.log('\n curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash\n'); + console.log(`Then: nvm install ${REQUIRED_NODE_VERSION}\n`); process.exit(1); } - // Check if Node 10 is installed via nvm, if not prompt to install it if (!checkNodeVersionAvailable(nvmPath)) { log.warn(`Node.js v${REQUIRED_NODE_VERSION} is not installed.`); - const shouldInstall = await confirm(`Install Node.js v${REQUIRED_NODE_VERSION} via nvm?`); + if (!shouldInstall) { - log.info('Installation cancelled. Please install manually:'); - console.log(''); - console.log(` nvm install ${REQUIRED_NODE_VERSION}`); - console.log(''); + log.info(`Cancelled. Run: nvm install ${REQUIRED_NODE_VERSION}`); process.exit(0); } - const installed = await installNodeVersion(nvmPath); - if (!installed) { - log.error('Please install manually:'); - console.log(''); - console.log(` nvm install ${REQUIRED_NODE_VERSION}`); - console.log(''); + if (!await installNodeVersion(nvmPath)) { + log.error(`Run manually: nvm install ${REQUIRED_NODE_VERSION}`); process.exit(1); } } log.info(`Using nvm to switch to Node.js v${REQUIRED_NODE_VERSION}...`); - return { originalVersion, nvmPath }; } -/** - * Creates the command prefix to run commands in the correct NVM context. - */ -export function getNvmCommandPrefix(nvmPath: NvmPaths | null): string { - if (!nvmPath) return ''; - return `export NVM_DIR="${nvmPath.dir}" && . "${nvmPath.script}" && nvm use ${REQUIRED_NODE_VERSION} 2>/dev/null &&`; -} - -/** - * Executes a command within the context of the required Node version. - * Explicitly sources NVM script on every call to ensure correct environment. - */ -export function runWithNodeVersion(command: string, nvmPath: NvmPaths | null, options?: { silent?: boolean }): string { - const stdio = options?.silent ? 'pipe' : 'inherit'; - - if (!nvmPath) { - const result = execSync(command, { stdio, encoding: 'utf-8', shell: '/bin/bash' }); - return result || ''; - } - - const prefix = getNvmCommandPrefix(nvmPath); - const nvmCommand = `${prefix} ${command}`; - - const result = execSync(nvmCommand, { stdio, encoding: 'utf-8', shell: '/bin/bash' }); - return result || ''; -} - -/** - * Spawns a child process within the context of the required Node version. - * Handles signal propagation (SIGINT, SIGTERM) to the child process. - */ -export function spawnWithNodeVersion(command: string, nvmPath: NvmPaths | null): void { - const nvmCommand = nvmPath - ? `export NVM_DIR="${nvmPath.dir}" && . "${nvmPath.script}" && nvm use ${REQUIRED_NODE_VERSION} && ${command}` - : command; - - const child = spawn(nvmCommand, { - stdio: 'inherit', - shell: '/bin/bash', - }); - - child.on('error', (err) => { - console.error('Failed to start process:', err); - process.exit(1); - }); - - const killChild = (signal: NodeJS.Signals) => { - if (child.pid) child.kill(signal); - }; - - process.on('SIGINT', () => killChild('SIGINT')); - process.on('SIGTERM', () => killChild('SIGTERM')); +export function createNvmCommand(command: string, nvmPath: NvmPaths | null): string { + return nvmPath ? `${getNvmPrefix(nvmPath)} ${command}` : command; } -export function getRequiredNodeVersion(): string { - return REQUIRED_NODE_VERSION; +export function runWithNodeVersion(command: string, nvmPath: NvmPaths | null, options?: { silent?: boolean; verbose?: boolean }): string { + const silent = options?.silent && !options?.verbose; + const fullCmd = nvmPath ? `${getNvmPrefix(nvmPath)} ${command}` : command; + return execSync(fullCmd, { stdio: silent ? 'pipe' : 'inherit', encoding: 'utf-8', shell: '/bin/bash' }) || ''; } diff --git a/gitbook-plugins/src/cli/server.ts b/gitbook-plugins/src/cli/server.ts index 4ed71a735..9d1b2fd11 100644 --- a/gitbook-plugins/src/cli/server.ts +++ b/gitbook-plugins/src/cli/server.ts @@ -1,53 +1,20 @@ -import { execSync, spawn, ChildProcess } from 'child_process'; +import { execSync } from 'child_process'; import { log } from './log'; import { DEFAULT_PORT, LIVERELOAD_PORT } from './constants'; function killProcessOnPort(port: number): void { try { - // Find PID(s) using the port const pids = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); - if (pids) { - log.warn(`Killing existing server on port ${port} (PIDs: ${pids.replace(/\n/g, ', ')})...`); - // Kill the processes + log.warn(`Killing server on port ${port} (PIDs: ${pids.replace(/\n/g, ', ')})`); execSync(`kill -9 ${pids.split('\n').join(' ')}`, { stdio: 'ignore' }); } - } catch { - // No process on port or lsof failed, which is expected/fine - } + } catch { /* No process on port */ } } -export function killExistingServers(port: number = DEFAULT_PORT): void { +export const killExistingServers = (port = DEFAULT_PORT) => { killProcessOnPort(LIVERELOAD_PORT); killProcessOnPort(port); -} - -export function getDefaultPort(): number { - return DEFAULT_PORT; -} - -export class ServerManager { - private port: number; - private process: ChildProcess | null = null; - private bookPath: string; +}; - constructor(port: number, bookPath: string) { - this.port = port; - this.bookPath = bookPath; - } - - public start(): void { - if (this.process) return; - const serveCmd = `npx --yes serve "${this.bookPath}" -l ${this.port} --no-request-logging`; - this.process = spawn(serveCmd, { stdio: 'inherit', shell: '/bin/bash' }); - this.process.on('error', (err) => log.error(`Server error: ${err.message}`)); - this.process.on('close', () => { this.process = null; }); - } - - public stop(): void { - if (this.process) { - this.process.kill(); - this.process = null; - } - } -} +export const getDefaultPort = () => DEFAULT_PORT; diff --git a/gitbook-plugins/src/cli/watch.ts b/gitbook-plugins/src/cli/watch.ts index 69ca328cc..634f3567c 100644 --- a/gitbook-plugins/src/cli/watch.ts +++ b/gitbook-plugins/src/cli/watch.ts @@ -1,15 +1,12 @@ import { spawn, ChildProcess } from 'child_process'; import { watch, FSWatcher } from 'chokidar'; -import { existsSync, rmSync, renameSync } from 'fs'; +import { existsSync, renameSync } from 'fs'; import { resolve } from 'path'; import { log, spin, confirm } from './log'; -import { getRequiredNodeVersion, NvmPaths } from './node-version'; +import { NvmPaths, createNvmCommand } from './node-version'; +import { getGitbookBin } from './gitbook'; import { killExistingServers } from './server'; - -const CONFIG = { - DEBOUNCE_MS: 1000, - MAX_FILES_TO_SHOW: 3, -} as const; +import { CONFIG, rmRecursive, SERVE_PATH_PREFIX } from './constants'; type BuildState = 'idle' | 'building' | 'cancelling'; @@ -21,307 +18,233 @@ export interface WatchOptions { } class BuildManager { - private options: WatchOptions; private bookPath: string; - private tempBookPath: string; - - private serverProcess: ChildProcess | null = null; - private buildProcess: ChildProcess | null = null; - private buildProcessPid: number | null = null; + private tempPath: string; + private serverProc: ChildProcess | null = null; + private buildProc: ChildProcess | null = null; + private buildPid: number | null = null; private watcher: FSWatcher | null = null; + private state: BuildState = 'idle'; + private debounce: NodeJS.Timeout | null = null; + private cancelTimeout: NodeJS.Timeout | null = null; + private pending = new Set(); + private current: string[] = []; - private buildState: BuildState = 'idle'; - private debounceTimer: NodeJS.Timeout | null = null; - private cancelTimer: NodeJS.Timeout | null = null; - - private pendingFiles: Set = new Set(); - private currentBuildFiles: string[] = []; - - constructor(options: WatchOptions) { - this.options = options; - this.bookPath = resolve(options.projectRoot, '_book'); - this.tempBookPath = resolve(options.projectRoot, '_book_temp'); + constructor(private opts: WatchOptions) { + this.bookPath = resolve(opts.projectRoot, '_book'); + this.tempPath = resolve(opts.projectRoot, '_book_temp'); } - public async start(): Promise { - this.cleanupTemp(); + async start() { + log.debug(`Project: ${this.opts.projectRoot}`, this.opts.verbose); + log.debug(`Book: ${this.bookPath}`, this.opts.verbose); + log.debug(`Port: ${this.opts.port}`, this.opts.verbose); + this.cleanup(); if (existsSync(this.bookPath)) { log.info('Using existing _book...'); - const shouldRebuild = await confirm('Rebuild before starting?'); - if (shouldRebuild) { - this.runInitialBuild(); + if (await confirm('Rebuild before starting?')) { + this.initialBuild(); } else { this.startServer(); this.setupWatcher(); } } else { - this.runInitialBuild(); + this.initialBuild(); } - - this.setupSignalHandlers(); + this.setupSignals(); } - private createNvmCommand(command: string): string { - const { nvmPath } = this.options; - if (!nvmPath) return command; - const version = getRequiredNodeVersion(); - return `export NVM_DIR="${nvmPath.dir}" && . "${nvmPath.script}" && nvm use ${version} 2>/dev/null && ${command}`; - } - - private cleanupTemp(): void { - try { - if (existsSync(this.tempBookPath)) { - rmSync(this.tempBookPath, { recursive: true, force: true }); - } - } catch { - // Ignore cleanup errors - } + private cleanup() { + try { if (existsSync(this.tempPath)) rmRecursive(this.tempPath); } catch { /* ignore */ } } private formatFiles(files: string[] | Set): string { - const arr = Array.isArray(files) ? files : Array.from(files); - if (arr.length === 0) return ''; + const arr = Array.isArray(files) ? files : [...files]; if (arr.length <= CONFIG.MAX_FILES_TO_SHOW) return arr.join(', '); return `${arr.slice(0, CONFIG.MAX_FILES_TO_SHOW).join(', ')} +${arr.length - CONFIG.MAX_FILES_TO_SHOW} more`; } - private startServer(): void { - if (this.serverProcess) return; - const serveCmd = `npx --yes serve "${this.bookPath}" -l ${this.options.port} --no-request-logging`; - this.serverProcess = spawn(serveCmd, { stdio: 'inherit', shell: '/bin/bash' }); - this.serverProcess.on('error', (err) => log.error(`Server error: ${err.message}`)); - this.serverProcess.on('close', () => { this.serverProcess = null; }); - log.info(`Server: http://localhost:${this.options.port}`); + private startServer() { + if (this.serverProc) return; + const quiet = this.opts.verbose ? '' : ' --no-request-logging 2>/dev/null'; + const cmd = `${SERVE_PATH_PREFIX} npx --yes serve@14 "${this.bookPath}" -l ${this.opts.port}${quiet}`; + log.debug(`Server: ${cmd}`, this.opts.verbose); + + this.serverProc = spawn(cmd, { stdio: 'inherit', shell: '/bin/bash', detached: true }); + this.serverProc.unref(); + this.serverProc.on('error', (e) => log.error(`Server error: ${e.message}`)); + this.serverProc.on('close', (code) => { + if (code && code !== 0) log.error(`Server exited: ${code}`); + this.serverProc = null; + }); } - private swapBuildOutput(): boolean { + private swap(): boolean { try { - if (existsSync(this.bookPath)) { - rmSync(this.bookPath, { recursive: true, force: true }); - } - if (existsSync(this.tempBookPath)) { - renameSync(this.tempBookPath, this.bookPath); - return true; - } + if (existsSync(this.bookPath)) rmRecursive(this.bookPath); + if (existsSync(this.tempPath)) { renameSync(this.tempPath, this.bookPath); return true; } return false; - } catch (err) { - log.error(`Failed to swap build output: ${err}`); - this.cleanupTemp(); + } catch (e) { + log.error(`Swap failed: ${e}`); + this.cleanup(); return false; } } - private startBuild(): void { - this.currentBuildFiles = Array.from(this.pendingFiles); - this.pendingFiles.clear(); - this.buildState = 'building'; - this.cleanupTemp(); + private rebuild() { + this.current = [...this.pending]; + this.pending.clear(); + this.state = 'building'; + this.cleanup(); - const filesMsg = this.formatFiles(this.currentBuildFiles); - spin.start(filesMsg ? `Rebuilding (${filesMsg})...` : 'Rebuilding...'); + const msg = this.current.length ? `Rebuilding (${this.formatFiles(this.current)})...` : 'Rebuilding...'; + spin.start(msg); - const buildCmd = this.createNvmCommand(`gitbook build . "${this.tempBookPath}" 2>&1`); + const cmd = createNvmCommand(`"${getGitbookBin()}" build . "${this.tempPath}" 2>&1`, this.opts.nvmPath); + log.debug(`Build: ${cmd}`, this.opts.verbose); - this.buildProcess = spawn(buildCmd, { - stdio: 'pipe', - shell: '/bin/bash', - cwd: this.options.projectRoot, - detached: true, - }); + this.buildProc = spawn(cmd, { stdio: 'pipe', shell: '/bin/bash', cwd: this.opts.projectRoot, detached: true }); + this.buildPid = this.buildProc.pid ? -this.buildProc.pid : null; - this.buildProcessPid = this.buildProcess.pid ? -this.buildProcess.pid : null; - - let output = ''; - this.buildProcess.stdout?.on('data', (data) => { output += data.toString(); }); - this.buildProcess.stderr?.on('data', (data) => { output += data.toString(); }); - - this.buildProcess.on('close', (code, signal) => this.handleBuildClose(code, signal, output)); - this.buildProcess.on('error', () => this.handleBuildError()); + let out = ''; + this.buildProc.stdout?.on('data', (d) => { out += d; }); + this.buildProc.stderr?.on('data', (d) => { out += d; }); + this.buildProc.on('close', (code, sig) => this.onBuildClose(code, sig, out)); + this.buildProc.on('error', () => this.onBuildError()); } - private handleBuildClose(code: number | null, signal: NodeJS.Signals | null, output: string): void { - this.buildProcess = null; - this.buildProcessPid = null; - const wasCancelled = this.buildState === 'cancelling' || signal != null; - this.buildState = 'idle'; + private onBuildClose(code: number | null, sig: NodeJS.Signals | null, out: string) { + this.buildProc = null; + this.buildPid = null; + const cancelled = this.state === 'cancelling' || sig != null; + this.state = 'idle'; - if (wasCancelled) { - this.cleanupTemp(); - this.currentBuildFiles.forEach(f => this.pendingFiles.add(f)); - if (this.pendingFiles.size > 0) this.startBuild(); + if (cancelled) { + this.cleanup(); + this.current.forEach(f => this.pending.add(f)); + if (this.pending.size) this.rebuild(); return; } - const hasSuccess = /generation finished with success/i.test(output); - if ((code === 0 || hasSuccess) && this.swapBuildOutput()) { + if (this.opts.verbose && out) console.log(out); + + const ok = code === 0 || /generation finished with success/i.test(out); + if (ok && this.swap()) { spin.succeed('Rebuild complete'); } else { spin.fail('Rebuild failed'); - this.logBuildErrors(output); - this.cleanupTemp(); - } - - if (this.pendingFiles.size > 0) { - this.startBuild(); + if (out) console.log(out); + this.cleanup(); } - } - private handleBuildError(): void { - this.buildProcess = null; - this.buildProcessPid = null; - this.buildState = 'idle'; - spin.fail('Build process error'); - this.cleanupTemp(); + if (this.pending.size) this.rebuild(); } - private logBuildErrors(output: string): void { - const errorLines = output.split('\n').filter(line => - /Error:|error:|TypeError|ENOENT|Template render error/.test(line) - ); - if (errorLines.length > 0) { - log.error(errorLines.join('\n')); - } else { - log.error('See console for details'); - console.log(output.slice(-500)); - } + private onBuildError() { + this.buildProc = null; + this.buildPid = null; + this.state = 'idle'; + spin.fail('Build error'); + this.cleanup(); } - private cancelBuild(): void { - if (!this.buildProcess || this.buildState !== 'building') return; - this.buildState = 'cancelling'; - - if (this.buildProcessPid) { - try { process.kill(this.buildProcessPid, 'SIGTERM'); } catch { } - } else if (this.buildProcess.pid) { - this.buildProcess.kill('SIGTERM'); - } + private cancelBuild() { + if (!this.buildProc || this.state !== 'building') return; + this.state = 'cancelling'; + if (this.buildPid) try { process.kill(this.buildPid, 'SIGTERM'); } catch { /* ignore */ } + else this.buildProc.kill('SIGTERM'); setTimeout(() => { - if (this.buildProcessPid) { - try { process.kill(this.buildProcessPid, 'SIGKILL'); } catch { } - } else if (this.buildProcess?.pid) { - this.buildProcess.kill('SIGKILL'); - } + if (this.buildPid) try { process.kill(this.buildPid, 'SIGKILL'); } catch { /* ignore */ } + else this.buildProc?.kill('SIGKILL'); }, 1000); } - private onFileChange(file: string): void { - const isNewFile = !this.pendingFiles.has(file); - this.pendingFiles.add(file); + private onFileChange(file: string) { + const isNew = !this.pending.has(file); + this.pending.add(file); - if (this.buildState === 'building' || this.buildState === 'cancelling') { - if (isNewFile && this.options.verbose) { - log.info(`Queued: ${file}`); - } - if (this.cancelTimer) clearTimeout(this.cancelTimer); - this.cancelTimer = setTimeout(() => { - if (this.buildState === 'building') this.cancelBuild(); - }, 500); + if (this.state !== 'idle') { + if (isNew && this.opts.verbose) log.info(`Queued: ${file}`); + if (this.cancelTimeout) clearTimeout(this.cancelTimeout); + this.cancelTimeout = setTimeout(() => { if (this.state === 'building') this.cancelBuild(); }, 500); return; } - if (this.debounceTimer) clearTimeout(this.debounceTimer); - this.debounceTimer = setTimeout(() => { - this.debounceTimer = null; - if (this.buildState === 'idle' && this.pendingFiles.size > 0) { - this.startBuild(); - } + if (this.debounce) clearTimeout(this.debounce); + this.debounce = setTimeout(() => { + this.debounce = null; + if (this.state === 'idle' && this.pending.size) this.rebuild(); }, CONFIG.DEBOUNCE_MS); } - private setupWatcher(): void { + private setupWatcher() { log.info('Watching...'); + const root = this.opts.projectRoot; this.watcher = watch( - [ - resolve(this.options.projectRoot, '**/*.md'), - resolve(this.options.projectRoot, 'book.json'), - resolve(this.options.projectRoot, 'SUMMARY.md'), - ], + [resolve(root, '**/*.md'), resolve(root, 'book.json'), resolve(root, 'SUMMARY.md')], { - ignored: [ - resolve(this.options.projectRoot, '_book/**'), - resolve(this.options.projectRoot, '_book_temp/**'), - resolve(this.options.projectRoot, 'node_modules/**'), - resolve(this.options.projectRoot, 'gitbook-plugins/**'), - ], + ignored: [resolve(root, '_book/**'), resolve(root, '_book_temp/**'), resolve(root, 'node_modules/**'), resolve(root, 'gitbook-plugins/**')], ignoreInitial: true, persistent: true, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }, } ); - - const handleChange = (filePath: string) => { - this.onFileChange(filePath.replace(this.options.projectRoot + '/', '')); - }; - - this.watcher.on('change', handleChange); - this.watcher.on('add', handleChange); - this.watcher.on('unlink', handleChange); + const handle = (p: string) => this.onFileChange(p.replace(root + '/', '')); + this.watcher.on('change', handle).on('add', handle).on('unlink', handle); } - private runInitialBuild(): void { + private initialBuild() { spin.start('Building...'); - this.buildState = 'building'; + this.state = 'building'; - const buildCmd = this.createNvmCommand(`gitbook build . "${this.tempBookPath}" 2>&1`); + const cmd = createNvmCommand(`"${getGitbookBin()}" build . "${this.tempPath}" 2>&1`, this.opts.nvmPath); + log.debug(`Build: ${cmd}`, this.opts.verbose); - this.buildProcess = spawn(buildCmd, { - stdio: 'pipe', - shell: '/bin/bash', - cwd: this.options.projectRoot, - detached: true, - }); + this.buildProc = spawn(cmd, { stdio: 'pipe', shell: '/bin/bash', cwd: this.opts.projectRoot, detached: true }); + this.buildPid = this.buildProc.pid ? -this.buildProc.pid : null; - this.buildProcessPid = this.buildProcess.pid ? -this.buildProcess.pid : null; - let output = ''; + let out = ''; + this.buildProc.stdout?.on('data', (d) => { out += d; }); + this.buildProc.stderr?.on('data', (d) => { out += d; }); - this.buildProcess.stdout?.on('data', (data) => { output += data.toString(); }); - this.buildProcess.stderr?.on('data', (data) => { output += data.toString(); }); + this.buildProc.on('close', (code) => { + this.buildProc = null; + this.buildPid = null; + this.state = 'idle'; - this.buildProcess.on('close', (code) => { - this.buildProcess = null; - this.buildProcessPid = null; - this.buildState = 'idle'; + if (this.opts.verbose && out) console.log(out); - const hasSuccess = /generation finished with success/i.test(output); - if ((code === 0 || hasSuccess) && this.swapBuildOutput()) { + const ok = code === 0 || /generation finished with success/i.test(out); + if (ok && this.swap()) { spin.succeed('Build complete'); this.startServer(); this.setupWatcher(); } else { - spin.fail('Initial build failed'); - this.logBuildErrors(output); - this.cleanupTemp(); + spin.fail('Build failed'); + if (out) console.log(out); + this.cleanup(); process.exit(1); } }); } - private setupSignalHandlers(): void { - const cleanup = () => { - if (this.debounceTimer) clearTimeout(this.debounceTimer); - if (this.cancelTimer) clearTimeout(this.cancelTimer); + private setupSignals() { + const exit = () => { + if (this.debounce) clearTimeout(this.debounce); + if (this.cancelTimeout) clearTimeout(this.cancelTimeout); this.watcher?.close(); - - if (this.buildProcessPid) { - try { process.kill(this.buildProcessPid, 'SIGKILL'); } catch { } - } else if (this.buildProcess) { - this.buildProcess.kill('SIGKILL'); - } - - if (this.serverProcess) this.serverProcess.kill(); - killExistingServers(this.options.port); - this.cleanupTemp(); + if (this.buildPid) try { process.kill(this.buildPid, 'SIGKILL'); } catch { /* ignore */ } + else this.buildProc?.kill('SIGKILL'); + this.serverProc?.kill(); + killExistingServers(this.opts.port); + this.cleanup(); process.exit(0); }; - - process.on('SIGINT', cleanup); - process.on('SIGTERM', cleanup); + process.on('SIGINT', exit); + process.on('SIGTERM', exit); } } -export async function watchAndServe(options: WatchOptions): Promise { - const manager = new BuildManager(options); - await manager.start(); -} +export const watchAndServe = async (opts: WatchOptions) => new BuildManager(opts).start(); diff --git a/gitbook-plugins/tsconfig.cli.json b/gitbook-plugins/tsconfig.cli.json index ab780d41d..869ad3d24 100644 --- a/gitbook-plugins/tsconfig.cli.json +++ b/gitbook-plugins/tsconfig.cli.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2018", "module": "commonjs", - "lib": ["ES2020"], + "lib": ["ES2018"], "outDir": "./dist/cli", "rootDir": "./src/cli", "strict": true, diff --git a/package.json b/package.json index 31b24f1f7..e36ee3ab2 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,12 @@ "build": "gitbook-cli build", "dev": "gitbook-cli serve", "preview": "gitbook-cli preview", + "build:verbose": "gitbook-cli build --verbose", + "dev:verbose": "gitbook-cli serve --verbose", + "preview:verbose": "gitbook-cli preview --verbose", "stop": "gitbook-cli stop", "build:plugin": "cd gitbook-plugins && npm install && npm run build", - "clean": "rm -rf _book _book_temp node_modules package-lock.json gitbook-plugins/node_modules gitbook-plugins/dist gitbook-plugins/package-lock.json" + "clean": "rm -rf _book _book_temp node_modules package-lock.json gitbook-plugins/node_modules gitbook-plugins/dist gitbook-plugins/package-lock.json .gitbook/cli/" }, "repository": { "type": "git",