diff --git a/.gitignore b/.gitignore index feb1e22a7..fb2ff2865 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ Thumbs.db :Zone.Identifier **/:Zone.Identifier lychee* +_book/ +_book_temp/ +node_modules/ +package-lock.json +.gitbook/cli/ \ No newline at end of file diff --git a/README.md b/README.md index 4e46324a1..5faa919cb 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. + ```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..eef56a246 --- /dev/null +++ b/book.json @@ -0,0 +1,4 @@ +{ + "gitbook": "3.2.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..2e567f068 --- /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": "^4.9.5" + } +} diff --git a/gitbook-plugins/src/cli/builder.ts b/gitbook-plugins/src/cli/builder.ts new file mode 100644 index 000000000..3ace9cf7b --- /dev/null +++ b/gitbook-plugins/src/cli/builder.ts @@ -0,0 +1,83 @@ +import { spawn } from 'child_process'; +import { existsSync, renameSync } from 'fs'; +import { log, spin } from './log'; +import { setupNodeVersion, createNvmCommand } from './node-version'; +import { getGitbookBin } from './gitbook'; +import { CONFIG, getBookPath, getTempBookPath, rmRecursive } from './constants'; + +type NvmPath = Awaited>['nvmPath']; + +export class Builder { + constructor( + private projectRoot: string, + private nvmPath: NvmPath, + private verbose = false + ) {} + + async build(files: string[] = []): Promise { + const tempPath = getTempBookPath(this.projectRoot); + const bookPath = getBookPath(this.projectRoot); + this.cleanup(tempPath); + + 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(isRebuild ? `Rebuilding (${filesMsg})...` : 'Building...'); + + return new Promise((resolve) => { + 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 = ''; + + proc.stdout?.on('data', (d) => { output += d; }); + proc.stderr?.on('data', (d) => { output += d; }); + + proc.on('close', (code) => { + if (this.verbose && output) console.log(output); + + 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(isRebuild ? 'Rebuild failed' : 'Build failed'); + if (output) console.log(output); + this.cleanup(tempPath); + resolve(false); + } + }); + + proc.on('error', () => { + spin.fail('Build process error'); + this.cleanup(tempPath); + resolve(false); + }); + }); + } + + private cleanup(path: string) { + try { if (existsSync(path)) rmRecursive(path); } catch { /* ignore */ } + } + + private swap(tempPath: string, bookPath: string): boolean { + try { + 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.cleanup(tempPath); + 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`; + } +} diff --git a/gitbook-plugins/src/cli/commands.ts b/gitbook-plugins/src/cli/commands.ts new file mode 100644 index 000000000..e98c3bd9e --- /dev/null +++ b/gitbook-plugins/src/cli/commands.ts @@ -0,0 +1,121 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { resolve } from 'path'; +import { confirm, log, spin } from './log'; +import { checkGitbookInstalled, ensureGitbookReady, getGitbookBin, installGitbook } from './gitbook'; +import { getDefaultPort, killExistingServers } from './server'; +import { Builder } from './builder'; +import { watchAndServe } from './watch'; +import { runWithNodeVersion, setupNodeVersion } from './node-version'; +import { REQUIRED_GITBOOK_VERSION, SERVE_PATH_PREFIX } from './constants'; + +export interface CommandOptions { + port?: number; + verbose?: boolean; +} + +const getRoot = () => resolve(__dirname, '..', '..', '..'); + +export async function build(opts: CommandOptions = {}) { + const verbose = opts.verbose ?? false; + const { nvmPath } = await ensureGitbookReady(); + const root = getRoot(); + log.debug(`Project: ${root}`, verbose); + await new Builder(root, nvmPath, verbose).build(); +} + +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: root, nvmPath, verbose }); +} + +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. Run "npm run build" first.'); + process.exit(1); + } + + killExistingServers(port); + 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(`${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 server. Install serve: npm install -g serve'); + process.exit(1); + } + } +} + +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('Stopped'); +} + +export async function setup(opts: CommandOptions = {}) { + const verbose = opts.verbose ?? false; + const { nvmPath } = await setupNodeVersion(); + + 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...'); + if (!await installGitbook(nvmPath)) { + spin.fail('Install failed'); + process.exit(1); + } + spin.succeed('gitbook-cli installed'); + } else { + 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! 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 new file mode 100644 index 000000000..e24e381d7 --- /dev/null +++ b/gitbook-plugins/src/cli/constants.ts @@ -0,0 +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; + +// 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; + +// 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 new file mode 100644 index 000000000..c3ee22188 --- /dev/null +++ b/gitbook-plugins/src/cli/gitbook.ts @@ -0,0 +1,32 @@ +import { existsSync } from 'fs'; +import { resolve } from 'path'; +import { log } from './log'; +import { runWithNodeVersion, setupNodeVersion } from './node-version'; + +type NvmPath = Awaited>['nvmPath']; + +const getGitbookDir = () => resolve(__dirname, '..', '..', '..', '.gitbook', 'cli'); + +export const getGitbookBin = () => resolve(getGitbookDir(), 'node_modules', '.bin', 'gitbook'); + +export const checkGitbookInstalled = () => existsSync(getGitbookBin()); + +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<{ 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 result; +} diff --git a/gitbook-plugins/src/cli/index.ts b/gitbook-plugins/src/cli/index.ts new file mode 100644 index 000000000..e6ff4db33 --- /dev/null +++ b/gitbook-plugins/src/cli/index.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +import { build, preview, serve, setup, stop } from './commands'; +import { printBanner, printUsage } from './log'; +import { getDefaultPort } from './server'; + +const parseArgs = (args: string[]) => { + let command = 'build', port: number | undefined, verbose = false; + for (let i = 0; i < args.length; i++) { + 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 }; +}; + +const main = async () => { + const { command, port, verbose } = parseArgs(process.argv.slice(2)); + printBanner(); + 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((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 new file mode 100644 index 000000000..1e8d1721c --- /dev/null +++ b/gitbook-plugins/src/cli/log.ts @@ -0,0 +1,53 @@ +import ora, { Ora } from 'ora'; +import pc from 'picocolors'; +import * as readline from 'readline'; + +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 (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 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 const printBanner = () => { + console.log(''); + console.log(pc.cyan('========================================')); + console.log(pc.cyan(' Filecoin Docs - GitBook Build Script')); + console.log(pc.cyan('========================================')); + 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\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 new file mode 100644 index 000000000..35a94a918 --- /dev/null +++ b/gitbook-plugins/src/cli/node-version.ts @@ -0,0 +1,111 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { log, spin, confirm } from './log'; +import { REQUIRED_NODE_VERSION } from './constants'; + +export interface NvmPaths { + script: string; + dir: string; +} + +function findNvmPath(): NvmPaths | null { + const homeDir = process.env.HOME || ''; + const nvmDir = process.env.NVM_DIR || join(homeDir, '.nvm'); + + 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 brew prefix + try { + 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 */ } + + return paths.find(p => existsSync(p.script)) || null; +} + +function getNodeMajorVersion(): number { + try { + return parseInt(execSync('node -v', { encoding: 'utf-8' }).trim().replace('v', '').split('.')[0], 10); + } catch { + return 0; + } +} + +function checkNodeVersionAvailable(nvmPath: NvmPaths): boolean { + try { + 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; + } +} + +async function installNodeVersion(nvmPath: NvmPaths): Promise { + spin.start(`Installing Node.js v${REQUIRED_NODE_VERSION} via nvm...`); + try { + 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}`); + log.error(err instanceof Error ? err.message : String(err)); + return false; + } +} + +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 = execSync('node -v', { encoding: 'utf-8' }).trim(); + 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('\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); + } + + 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(`Cancelled. Run: nvm install ${REQUIRED_NODE_VERSION}`); + process.exit(0); + } + + 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 }; +} + +export function createNvmCommand(command: string, nvmPath: NvmPaths | null): string { + return nvmPath ? `${getNvmPrefix(nvmPath)} ${command}` : command; +} + +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 new file mode 100644 index 000000000..9d1b2fd11 --- /dev/null +++ b/gitbook-plugins/src/cli/server.ts @@ -0,0 +1,20 @@ +import { execSync } from 'child_process'; +import { log } from './log'; +import { DEFAULT_PORT, LIVERELOAD_PORT } from './constants'; + +function killProcessOnPort(port: number): void { + try { + const pids = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + if (pids) { + 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 */ } +} + +export const killExistingServers = (port = DEFAULT_PORT) => { + killProcessOnPort(LIVERELOAD_PORT); + killProcessOnPort(port); +}; + +export const getDefaultPort = () => DEFAULT_PORT; diff --git a/gitbook-plugins/src/cli/watch.ts b/gitbook-plugins/src/cli/watch.ts new file mode 100644 index 000000000..634f3567c --- /dev/null +++ b/gitbook-plugins/src/cli/watch.ts @@ -0,0 +1,250 @@ +import { spawn, ChildProcess } from 'child_process'; +import { watch, FSWatcher } from 'chokidar'; +import { existsSync, renameSync } from 'fs'; +import { resolve } from 'path'; +import { log, spin, confirm } from './log'; +import { NvmPaths, createNvmCommand } from './node-version'; +import { getGitbookBin } from './gitbook'; +import { killExistingServers } from './server'; +import { CONFIG, rmRecursive, SERVE_PATH_PREFIX } from './constants'; + +type BuildState = 'idle' | 'building' | 'cancelling'; + +export interface WatchOptions { + port: number; + projectRoot: string; + nvmPath: NvmPaths | null; + verbose?: boolean; +} + +class BuildManager { + private bookPath: string; + 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[] = []; + + constructor(private opts: WatchOptions) { + this.bookPath = resolve(opts.projectRoot, '_book'); + this.tempPath = resolve(opts.projectRoot, '_book_temp'); + } + + 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...'); + if (await confirm('Rebuild before starting?')) { + this.initialBuild(); + } else { + this.startServer(); + this.setupWatcher(); + } + } else { + this.initialBuild(); + } + this.setupSignals(); + } + + private cleanup() { + try { if (existsSync(this.tempPath)) rmRecursive(this.tempPath); } catch { /* ignore */ } + } + + private formatFiles(files: string[] | Set): string { + 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() { + 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 swap(): boolean { + try { + if (existsSync(this.bookPath)) rmRecursive(this.bookPath); + if (existsSync(this.tempPath)) { renameSync(this.tempPath, this.bookPath); return true; } + return false; + } catch (e) { + log.error(`Swap failed: ${e}`); + this.cleanup(); + return false; + } + } + + private rebuild() { + this.current = [...this.pending]; + this.pending.clear(); + this.state = 'building'; + this.cleanup(); + + const msg = this.current.length ? `Rebuilding (${this.formatFiles(this.current)})...` : 'Rebuilding...'; + spin.start(msg); + + const cmd = createNvmCommand(`"${getGitbookBin()}" build . "${this.tempPath}" 2>&1`, this.opts.nvmPath); + log.debug(`Build: ${cmd}`, this.opts.verbose); + + this.buildProc = spawn(cmd, { stdio: 'pipe', shell: '/bin/bash', cwd: this.opts.projectRoot, detached: true }); + this.buildPid = this.buildProc.pid ? -this.buildProc.pid : null; + + 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 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 (cancelled) { + this.cleanup(); + this.current.forEach(f => this.pending.add(f)); + if (this.pending.size) this.rebuild(); + return; + } + + 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'); + if (out) console.log(out); + this.cleanup(); + } + + if (this.pending.size) this.rebuild(); + } + + private onBuildError() { + this.buildProc = null; + this.buildPid = null; + this.state = 'idle'; + spin.fail('Build error'); + this.cleanup(); + } + + 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.buildPid) try { process.kill(this.buildPid, 'SIGKILL'); } catch { /* ignore */ } + else this.buildProc?.kill('SIGKILL'); + }, 1000); + } + + private onFileChange(file: string) { + const isNew = !this.pending.has(file); + this.pending.add(file); + + 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.debounce) clearTimeout(this.debounce); + this.debounce = setTimeout(() => { + this.debounce = null; + if (this.state === 'idle' && this.pending.size) this.rebuild(); + }, CONFIG.DEBOUNCE_MS); + } + + private setupWatcher() { + log.info('Watching...'); + const root = this.opts.projectRoot; + this.watcher = watch( + [resolve(root, '**/*.md'), resolve(root, 'book.json'), resolve(root, 'SUMMARY.md')], + { + 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 handle = (p: string) => this.onFileChange(p.replace(root + '/', '')); + this.watcher.on('change', handle).on('add', handle).on('unlink', handle); + } + + private initialBuild() { + spin.start('Building...'); + this.state = 'building'; + + const cmd = createNvmCommand(`"${getGitbookBin()}" build . "${this.tempPath}" 2>&1`, this.opts.nvmPath); + log.debug(`Build: ${cmd}`, this.opts.verbose); + + this.buildProc = spawn(cmd, { stdio: 'pipe', shell: '/bin/bash', cwd: this.opts.projectRoot, detached: true }); + this.buildPid = this.buildProc.pid ? -this.buildProc.pid : null; + + let out = ''; + this.buildProc.stdout?.on('data', (d) => { out += d; }); + this.buildProc.stderr?.on('data', (d) => { out += d; }); + + this.buildProc.on('close', (code) => { + this.buildProc = null; + this.buildPid = null; + this.state = 'idle'; + + 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('Build complete'); + this.startServer(); + this.setupWatcher(); + } else { + spin.fail('Build failed'); + if (out) console.log(out); + this.cleanup(); + process.exit(1); + } + }); + } + + private setupSignals() { + const exit = () => { + if (this.debounce) clearTimeout(this.debounce); + if (this.cancelTimeout) clearTimeout(this.cancelTimeout); + this.watcher?.close(); + 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', exit); + process.on('SIGTERM', exit); + } +} + +export const watchAndServe = async (opts: WatchOptions) => new BuildManager(opts).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..869ad3d24 --- /dev/null +++ b/gitbook-plugins/tsconfig.cli.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2018", + "module": "commonjs", + "lib": ["ES2018"], + "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/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 ... diff --git a/package.json b/package.json index 6faa6b3f6..e36ee3ab2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,16 @@ "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", + "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 .gitbook/cli/" }, "repository": { "type": "git", @@ -17,5 +26,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" + } }