diff --git a/package-lock.json b/package-lock.json index b1b1c2a1e..627836862 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2214,6 +2214,7 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2253,6 +2254,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2273,6 +2275,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2293,6 +2296,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2313,6 +2317,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2333,6 +2338,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2353,6 +2359,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2373,6 +2380,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2393,6 +2401,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2413,6 +2422,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2433,6 +2443,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2453,6 +2464,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2473,6 +2485,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2493,6 +2506,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6247,6 +6261,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, "license": "Apache-2.0", "optional": true, "bin": { @@ -11703,6 +11718,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, "license": "MIT", "optional": true }, @@ -18592,6 +18608,7 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, "optional": true, "requires": { "@parcel/watcher-android-arm64": "2.5.1", @@ -18617,78 +18634,91 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "dev": true, "optional": true }, "@parcel/watcher-darwin-arm64": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "dev": true, "optional": true }, "@parcel/watcher-darwin-x64": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "dev": true, "optional": true }, "@parcel/watcher-freebsd-x64": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "dev": true, "optional": true }, "@parcel/watcher-linux-arm-glibc": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "dev": true, "optional": true }, "@parcel/watcher-linux-arm-musl": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "dev": true, "optional": true }, "@parcel/watcher-linux-arm64-glibc": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "dev": true, "optional": true }, "@parcel/watcher-linux-arm64-musl": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "dev": true, "optional": true }, "@parcel/watcher-linux-x64-glibc": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "dev": true, "optional": true }, "@parcel/watcher-linux-x64-musl": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "dev": true, "optional": true }, "@parcel/watcher-win32-arm64": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "dev": true, "optional": true }, "@parcel/watcher-win32-ia32": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "dev": true, "optional": true }, "@parcel/watcher-win32-x64": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "dev": true, "optional": true }, "@pdf-lib/standard-fonts": { @@ -21361,6 +21391,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, "optional": true }, "devtools-protocol": { @@ -24869,6 +24900,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, "optional": true }, "node-domexception": { diff --git a/packages/cli/docs/workflows.md b/packages/cli/docs/workflows.md index 736686b9d..1a1643e21 100644 --- a/packages/cli/docs/workflows.md +++ b/packages/cli/docs/workflows.md @@ -115,6 +115,35 @@ quire pdf --build --engine prince --output final-print.pdf --- +## Serving the Built Site + +Preview your built publication without live-reload or file watching: + +```bash +# Serve the built site (requires prior `quire build`) +quire serve + +# Build first if needed, then serve +quire serve --build + +# Use a custom port +quire serve --port 3000 + +# Open browser automatically +quire serve --build --open +``` + +### When to Use `serve` vs `preview` + +| Command | Use Case | +|---------|----------| +| `quire preview` | Active development with live-reload | +| `quire serve` | Final review of built output before deployment | + +The `serve` command starts a lightweight static file server for `_site/` without Eleventy's development overhead. Use it to verify the final build before deploying. + +--- + ## Generating EPUB Create an e-book version of your publication: @@ -167,10 +196,13 @@ quire clean # 2. Build the HTML site quire build -# 3. Generate PDF +# 3. Review the built site locally +quire serve --open + +# 4. Generate PDF quire pdf --open -# 4. Generate EPUB +# 5. Generate EPUB quire epub --open ``` diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js index d80c2085c..534818aba 100644 --- a/packages/cli/src/commands/build.js +++ b/packages/cli/src/commands/build.js @@ -2,8 +2,11 @@ import Command from '#src/Command.js' import { Option } from 'commander' import { withOutputModes } from '#lib/commander/index.js' import { api, cli } from '#lib/11ty/index.js' +import { serve } from '#lib/server/index.js' +import processManager from '#lib/process/manager.js' import paths from '#lib/project/index.js' import { clean } from '#helpers/clean.js' +import open from 'open' import { recordStatus } from '#lib/conf/build-status.js' import reporter from '#lib/reporter/index.js' import testcwd from '#helpers/test-cwd.js' @@ -25,6 +28,8 @@ export default class BuildCommand extends Command { helpText: ` Examples: quire build Build the site + quire build --serve Build and start static server + quire build --open Build, serve, and open browser quire build --verbose Build with detailed progress quire build --debug Build with debug output @@ -34,6 +39,9 @@ Note: Run before "quire pdf" or "quire epub" commands. options: [ [ '-d', '--dry-run', 'run build without writing files' ], [ '--dryrun', 'alias for --dry-run', { hidden: true, implies: { dryRun: true } } ], + [ '--serve', 'start static server after build', { conflicts: 'dryRun' } ], + [ '-p, --port ', 'server port', { default: 8080, implies: { serve: true } } ], + [ '--open', 'open in default browser after build', { implies: { serve: true } } ], // Use Option object syntax to configure this as a hidden option new Option('--11ty ', 'use the specified 11ty module') .choices(['api', 'cli']).default('api').hideHelp(), @@ -63,6 +71,23 @@ Note: Run before "quire pdf" or "quire epub" commands. recordStatus(paths.getProjectRoot(), 'build', 'failed') throw error } + + // Start static server after build if --serve flag is set + if (options.serve) { + const port = parseInt(options.port, 10) + + const { url, stop } = + await serve(paths.getSitePath(), { port, quiet: options.quiet, verbose: options.verbose }) + + processManager.onShutdown('serve', stop) + + try { + if (options.open) open(url) + await new Promise(() => {}) + } finally { + processManager.onShutdownComplete('serve') + } + } } preAction(thisCommand, actionCommand) { diff --git a/packages/cli/src/commands/build.spec.js b/packages/cli/src/commands/build.spec.js index e481e9c46..8cbf2c0fb 100644 --- a/packages/cli/src/commands/build.spec.js +++ b/packages/cli/src/commands/build.spec.js @@ -42,6 +42,9 @@ test('registered command has correct options', (t) => { // Get all options const dryRunOption = command.options.find((opt) => opt.long === '--dry-run') const dryrunAlias = command.options.find((opt) => opt.long === '--dryrun') + const serveOption = command.options.find((opt) => opt.long === '--serve') + const portOption = command.options.find((opt) => opt.long === '--port') + const openOption = command.options.find((opt) => opt.long === '--open') const quietOption = command.options.find((opt) => opt.long === '--quiet') const verboseOption = command.options.find((opt) => opt.long === '--verbose') const progressOption = command.options.find((opt) => opt.long === '--progress') @@ -51,6 +54,9 @@ test('registered command has correct options', (t) => { // Verify all options exist t.truthy(dryRunOption, '--dry-run option should exist') t.truthy(dryrunAlias, '--dryrun alias should exist') + t.truthy(serveOption, '--serve option should exist') + t.truthy(portOption, '--port option should exist') + t.truthy(openOption, '--open option should exist') t.truthy(quietOption, '--quiet option should exist') t.truthy(verboseOption, '--verbose option should exist') t.truthy(progressOption, '--progress option should exist') @@ -60,6 +66,9 @@ test('registered command has correct options', (t) => { // Verify they are Option instances t.true(dryRunOption instanceof Option, '--dry-run should be Option instance') t.true(dryrunAlias instanceof Option, '--dryrun should be Option instance') + t.true(serveOption instanceof Option, '--serve should be Option instance') + t.true(portOption instanceof Option, '--port should be Option instance') + t.true(openOption instanceof Option, '--open should be Option instance') t.true(quietOption instanceof Option, '--quiet should be Option instance') t.true(verboseOption instanceof Option, '--verbose should be Option instance') t.true(progressOption instanceof Option, '--progress should be Option instance') @@ -75,6 +84,16 @@ test('registered command has correct options', (t) => { t.is(dryrunAlias.long, '--dryrun') t.true(dryrunAlias.hidden, '--dryrun should be hidden from help') + t.is(serveOption.long, '--serve') + t.truthy(serveOption.description) + + t.is(portOption.long, '--port') + t.is(portOption.short, '-p') + t.truthy(portOption.description) + + t.is(openOption.long, '--open') + t.truthy(openOption.description) + t.is(quietOption.long, '--quiet') t.is(quietOption.short, '-q') t.truthy(quietOption.description) @@ -105,6 +124,9 @@ test('command options are accessible via public API', (t) => { t.true(optionNames.includes('--dry-run')) t.true(optionNames.includes('--dryrun')) + t.true(optionNames.includes('--serve')) + t.true(optionNames.includes('--port')) + t.true(optionNames.includes('--open')) t.true(optionNames.includes('--quiet')) t.true(optionNames.includes('--verbose')) t.true(optionNames.includes('--progress')) diff --git a/packages/cli/src/commands/build.test.js b/packages/cli/src/commands/build.test.js index 16e6953b5..11a8f81f9 100644 --- a/packages/cli/src/commands/build.test.js +++ b/packages/cli/src/commands/build.test.js @@ -395,3 +395,295 @@ test('build command should configure reporter with quiet option', async (t) => { 'reporter.configure should be called with quiet option' ) }) + +// ───────────────────────────────────────────────────────────────────────── +// --serve option tests +// ───────────────────────────────────────────────────────────────────────── + +test('build --serve should start static server after successful build', async (t) => { + const { sandbox, fs, mockLogger, mockReporter } = t.context + + const mockStop = sandbox.stub().resolves() + const mockServe = sandbox.stub().resolves({ + url: 'http://localhost:8080', + stop: mockStop + }) + const mockOnShutdown = sandbox.stub() + const mockOnShutdownComplete = sandbox.stub() + + const BuildCommand = await esmock('./build.js', { + '#lib/11ty/index.js': { + api: { build: sandbox.stub().resolves() }, + cli: { build: sandbox.stub().resolves() } + }, + '#lib/server/index.js': { + serve: mockServe + }, + '#lib/process/manager.js': { + default: { + onShutdown: mockOnShutdown, + onShutdownComplete: mockOnShutdownComplete + } + }, + '#lib/project/index.js': { + default: { + getProjectRoot: () => '/project', + getSitePath: () => '/project/_site', + toObject: () => ({ output: '_site' }) + } + }, + '#helpers/clean.js': { clean: sandbox.stub() }, + '#helpers/test-cwd.js': { default: sandbox.stub() }, + '#lib/logger/index.js': { logger: mockLogger }, + '#lib/reporter/index.js': { default: mockReporter }, + open: { default: sandbox.stub() }, + 'fs-extra': fs + }) + + const command = new BuildCommand() + command.name = sandbox.stub().returns('build') + + // Start the action but don't await (infinite promise from serve) + command.action({ '11ty': 'api', serve: true, port: 8080, quiet: true }, command) + + // Wait a tick for async operations + await new Promise(resolve => setImmediate(resolve)) + + t.true(mockServe.called, 'serve façade should be called') + t.is(mockServe.firstCall.args[0], '/project/_site', 'should serve from _site directory') + t.true(mockOnShutdown.called, 'should register cleanup handler') + t.is(mockOnShutdown.firstCall.args[0], 'serve', 'should register with name "serve"') +}) + +test('build --serve should not start server without --serve flag', async (t) => { + const { sandbox, fs, mockLogger, mockReporter } = t.context + + const mockServe = sandbox.stub() + + const BuildCommand = await esmock('./build.js', { + '#lib/11ty/index.js': { + api: { build: sandbox.stub().resolves() }, + cli: { build: sandbox.stub().resolves() } + }, + '#lib/server/index.js': { + serve: mockServe + }, + '#lib/process/manager.js': { + default: { + onShutdown: sandbox.stub(), + onShutdownComplete: sandbox.stub() + } + }, + '#lib/project/index.js': { + default: { + getProjectRoot: () => '/project', + getSitePath: () => '/project/_site', + toObject: () => ({ output: '_site' }) + } + }, + '#helpers/clean.js': { clean: sandbox.stub() }, + '#helpers/test-cwd.js': { default: sandbox.stub() }, + '#lib/logger/index.js': { logger: mockLogger }, + '#lib/reporter/index.js': { default: mockReporter }, + open: { default: sandbox.stub() }, + 'fs-extra': fs + }) + + const command = new BuildCommand() + command.name = sandbox.stub().returns('build') + + await command.action({ '11ty': 'api' }, command) + + t.false(mockServe.called, 'serve should not be called without --serve flag') +}) + +test('build --serve --open should open browser after starting server', async (t) => { + const { sandbox, fs, mockLogger, mockReporter } = t.context + + const mockOpen = sandbox.stub() + const mockServe = sandbox.stub().resolves({ + url: 'http://localhost:3000', + stop: sandbox.stub().resolves() + }) + + const BuildCommand = await esmock('./build.js', { + '#lib/11ty/index.js': { + api: { build: sandbox.stub().resolves() }, + cli: { build: sandbox.stub().resolves() } + }, + '#lib/server/index.js': { + serve: mockServe + }, + '#lib/process/manager.js': { + default: { + onShutdown: sandbox.stub(), + onShutdownComplete: sandbox.stub() + } + }, + '#lib/project/index.js': { + default: { + getProjectRoot: () => '/project', + getSitePath: () => '/project/_site', + toObject: () => ({ output: '_site' }) + } + }, + '#helpers/clean.js': { clean: sandbox.stub() }, + '#helpers/test-cwd.js': { default: sandbox.stub() }, + '#lib/logger/index.js': { logger: mockLogger }, + '#lib/reporter/index.js': { default: mockReporter }, + open: { default: mockOpen }, + 'fs-extra': fs + }) + + const command = new BuildCommand() + command.name = sandbox.stub().returns('build') + + command.action({ '11ty': 'api', serve: true, port: 3000, open: true, quiet: true }, command) + + await new Promise(resolve => setImmediate(resolve)) + + t.true(mockOpen.called, 'open should be called when --open flag is provided') + t.is(mockOpen.firstCall.args[0], 'http://localhost:3000', 'should open correct URL') +}) + +test('build --serve should not open browser without --open flag', async (t) => { + const { sandbox, fs, mockLogger, mockReporter } = t.context + + const mockOpen = sandbox.stub() + const mockServe = sandbox.stub().resolves({ + url: 'http://localhost:8080', + stop: sandbox.stub().resolves() + }) + + const BuildCommand = await esmock('./build.js', { + '#lib/11ty/index.js': { + api: { build: sandbox.stub().resolves() }, + cli: { build: sandbox.stub().resolves() } + }, + '#lib/server/index.js': { + serve: mockServe + }, + '#lib/process/manager.js': { + default: { + onShutdown: sandbox.stub(), + onShutdownComplete: sandbox.stub() + } + }, + '#lib/project/index.js': { + default: { + getProjectRoot: () => '/project', + getSitePath: () => '/project/_site', + toObject: () => ({ output: '_site' }) + } + }, + '#helpers/clean.js': { clean: sandbox.stub() }, + '#helpers/test-cwd.js': { default: sandbox.stub() }, + '#lib/logger/index.js': { logger: mockLogger }, + '#lib/reporter/index.js': { default: mockReporter }, + open: { default: mockOpen }, + 'fs-extra': fs + }) + + const command = new BuildCommand() + command.name = sandbox.stub().returns('build') + + command.action({ '11ty': 'api', serve: true, port: 8080, quiet: true }, command) + + await new Promise(resolve => setImmediate(resolve)) + + t.false(mockOpen.called, 'open should not be called without --open flag') +}) + +test('build --serve should not start server when build fails', async (t) => { + const { sandbox, fs, mockLogger, mockReporter } = t.context + + const mockServe = sandbox.stub() + + const BuildCommand = await esmock('./build.js', { + '#lib/11ty/index.js': { + api: { build: sandbox.stub().rejects(new Error('Build failed')) }, + cli: { build: sandbox.stub().resolves() } + }, + '#lib/server/index.js': { + serve: mockServe + }, + '#lib/process/manager.js': { + default: { + onShutdown: sandbox.stub(), + onShutdownComplete: sandbox.stub() + } + }, + '#lib/project/index.js': { + default: { + getProjectRoot: () => '/project', + getSitePath: () => '/project/_site', + toObject: () => ({ output: '_site' }) + } + }, + '#helpers/clean.js': { clean: sandbox.stub() }, + '#helpers/test-cwd.js': { default: sandbox.stub() }, + '#lib/logger/index.js': { logger: mockLogger }, + '#lib/reporter/index.js': { default: mockReporter }, + open: { default: sandbox.stub() }, + 'fs-extra': fs + }) + + const command = new BuildCommand() + command.name = sandbox.stub().returns('build') + + await t.throwsAsync( + () => command.action({ '11ty': 'api', serve: true, port: 8080 }, command), + { message: 'Build failed' } + ) + + t.false(mockServe.called, 'serve should not be called when build fails') +}) + +test('build --serve should pass port and options to serve façade', async (t) => { + const { sandbox, fs, mockLogger, mockReporter } = t.context + + const mockServe = sandbox.stub().resolves({ + url: 'http://localhost:3000', + stop: sandbox.stub().resolves() + }) + + const BuildCommand = await esmock('./build.js', { + '#lib/11ty/index.js': { + api: { build: sandbox.stub().resolves() }, + cli: { build: sandbox.stub().resolves() } + }, + '#lib/server/index.js': { + serve: mockServe + }, + '#lib/process/manager.js': { + default: { + onShutdown: sandbox.stub(), + onShutdownComplete: sandbox.stub() + } + }, + '#lib/project/index.js': { + default: { + getProjectRoot: () => '/project', + getSitePath: () => '/project/_site', + toObject: () => ({ output: '_site' }) + } + }, + '#helpers/clean.js': { clean: sandbox.stub() }, + '#helpers/test-cwd.js': { default: sandbox.stub() }, + '#lib/logger/index.js': { logger: mockLogger }, + '#lib/reporter/index.js': { default: mockReporter }, + open: { default: sandbox.stub() }, + 'fs-extra': fs + }) + + const command = new BuildCommand() + command.name = sandbox.stub().returns('build') + + command.action({ '11ty': 'api', serve: true, port: 3000, quiet: false, verbose: true }, command) + + await new Promise(resolve => setImmediate(resolve)) + + t.true(mockServe.called, 'serve façade should be called') + t.is(mockServe.firstCall.args[0], '/project/_site', 'should pass site path') + t.deepEqual(mockServe.firstCall.args[1], { port: 3000, quiet: false, verbose: true }, 'should pass port and output options') +}) diff --git a/packages/cli/src/commands/serve.js b/packages/cli/src/commands/serve.js new file mode 100644 index 000000000..11c62f177 --- /dev/null +++ b/packages/cli/src/commands/serve.js @@ -0,0 +1,95 @@ +import Command from '#src/Command.js' +import { withOutputModes } from '#lib/commander/index.js' +import paths, { hasSiteOutput } from '#lib/project/index.js' +import eleventy from '#lib/11ty/index.js' +import { serve } from '#lib/server/index.js' +import processManager from '#lib/process/manager.js' +import reporter from '#lib/reporter/index.js' +import open from 'open' +import testcwd from '#helpers/test-cwd.js' +import { MissingBuildOutputError } from '#src/errors/index.js' + +/** + * Quire CLI `serve` Command + * + * Starts a lightweight static file server for the built site output. + * Unlike `preview`, this does not run Eleventy's dev server with live-reload. + * + * @class ServeCommand + * @extends {Command} + */ +export default class ServeCommand extends Command { + static definition = withOutputModes({ + name: 'serve', + description: 'Start a static file server for the built site output', + summary: 'serve built site locally', + docsLink: 'quire-commands/#serve', + helpText: ` +Examples: + quire serve Serve _site/ on port 8080 + quire serve --port 3000 Use custom port + quire serve --build --open Build if needed, then open browser +`, + version: '1.0.0', + options: [ + ['-p, --port ', 'server port', 8080], + ['--build', 'run build first if output is missing'], + ['--open', 'open in default browser'], + ], + }) + + constructor() { + super(ServeCommand.definition) + } + + async action(options, command) { + this.debug('called with options %O', options) + + // Configure reporter for this command + reporter.configure({ quiet: options.quiet, verbose: options.verbose }) + + const port = parseInt(options.port, 10) + if (isNaN(port) || port < 1 || port > 65535) { + throw new Error(`Invalid port: ${options.port}`) + } + + // Run build first if --build flag is set and output is missing + if (options.build && !hasSiteOutput()) { + this.debug('running build before serving') + reporter.start('Building site...', { showElapsed: true }) + try { + await eleventy.build({ debug: options.debug }) + reporter.succeed('Build complete') + } catch (error) { + reporter.fail('Build failed') + throw error + } + } + + // Check for build output + if (!hasSiteOutput()) { + throw new MissingBuildOutputError('serve', paths.getSitePath()) + } + + // Start static file server (façade returns { url, stop }) + // Nota bene: Server status messages use logger (not reporter) because + // the server runs indefinitely - a spinner would never complete. + const { url, stop } = + await serve(paths.getSitePath(), { port, quiet: options.quiet, verbose: options.verbose }) + + // Register cleanup handler for graceful shutdown + processManager.onShutdown('serve', stop) + + try { + if (options.open) open(url); + // Keep the process running until shutdown signal + await new Promise(() => {}) + } finally { + processManager.onShutdownComplete('serve') + } + } + + preAction(thisCommand, actionCommand) { + testcwd(thisCommand) + } +} diff --git a/packages/cli/src/commands/serve.spec.js b/packages/cli/src/commands/serve.spec.js new file mode 100644 index 000000000..af2dcbd04 --- /dev/null +++ b/packages/cli/src/commands/serve.spec.js @@ -0,0 +1,128 @@ +import { Command, Option } from 'commander' +import program from '#src/main.js' +import test from 'ava' + +/** + * Command Contract/Interface Tests + * + * Verifies the command's public API and Commander.js integration. + * @see docs/testing-commands.md + */ + +test.before((t) => { + // Get the registered command (from program.commands) once and share across all tests + t.context.command = program.commands.find((cmd) => cmd.name() === 'serve') +}) + +test('command is registered in CLI program', (t) => { + const { command } = t.context + + t.truthy(command, 'command "serve" should be registered in program') + t.true(command instanceof Command, 'registered command should be Commander.js Command instance') +}) + +test('registered command has correct metadata', (t) => { + const { command } = t.context + + t.is(command.name(), 'serve') + t.truthy(command.description()) + t.is(typeof command._actionHandler, 'function', 'command should have action handler') +}) + +test('registered command has no arguments', (t) => { + const { command } = t.context + const registeredArguments = command.registeredArguments + + t.is(registeredArguments.length, 0, 'serve command should have no arguments') +}) + +test('registered command has correct options', (t) => { + const { command } = t.context + + // Get all options + const portOption = command.options.find((opt) => opt.long === '--port') + const buildOption = command.options.find((opt) => opt.long === '--build') + const openOption = command.options.find((opt) => opt.long === '--open') + const quietOption = command.options.find((opt) => opt.long === '--quiet') + const verboseOption = command.options.find((opt) => opt.long === '--verbose') + const progressOption = command.options.find((opt) => opt.long === '--progress') + const debugOption = command.options.find((opt) => opt.long === '--debug') + + // Verify all options exist + t.truthy(portOption, '--port option should exist') + t.truthy(buildOption, '--build option should exist') + t.truthy(openOption, '--open option should exist') + t.truthy(quietOption, '--quiet option should exist') + t.truthy(verboseOption, '--verbose option should exist') + t.truthy(progressOption, '--progress option should exist') + t.truthy(debugOption, '--debug option should exist') + + // Verify they are Option instances + t.true(portOption instanceof Option, '--port should be Option instance') + t.true(buildOption instanceof Option, '--build should be Option instance') + t.true(openOption instanceof Option, '--open should be Option instance') + t.true(quietOption instanceof Option, '--quiet should be Option instance') + t.true(verboseOption instanceof Option, '--verbose should be Option instance') + t.true(progressOption instanceof Option, '--progress should be Option instance') + t.true(debugOption instanceof Option, '--debug should be Option instance') + + // Verify --port option properties + t.is(portOption.long, '--port') + t.is(portOption.short, '-p') + t.truthy(portOption.description) + t.true(portOption.required, '--port should require a value when specified') + + // Verify --build option + t.is(buildOption.long, '--build') + t.truthy(buildOption.description) + t.false(buildOption.required, '--build should not require a value') + + // Verify --open option + t.is(openOption.long, '--open') + t.truthy(openOption.description) + t.false(openOption.required, '--open should not require a value') + + // Verify --quiet option + t.is(quietOption.long, '--quiet') + t.is(quietOption.short, '-q') + t.truthy(quietOption.description) + t.false(quietOption.required, '--quiet should not require a value') + + // Verify --verbose option + t.is(verboseOption.long, '--verbose') + t.is(verboseOption.short, '-v') + t.truthy(verboseOption.description) + t.false(verboseOption.required, '--verbose should not require a value') + + // --progress is a hidden alias for --verbose + t.is(progressOption.long, '--progress') + t.truthy(progressOption.description) + t.true(progressOption.hidden, '--progress should be hidden from help') + + // Verify --debug option + t.is(debugOption.long, '--debug') + t.truthy(debugOption.description) + t.false(debugOption.required, '--debug should not require a value') +}) + +test('command options are accessible via public API', (t) => { + const { command } = t.context + + // Test that options can be accessed the way Commander.js does + const optionNames = command.options.map((opt) => opt.long) + + t.true(optionNames.includes('--port')) + t.true(optionNames.includes('--build')) + t.true(optionNames.includes('--open')) + t.true(optionNames.includes('--quiet')) + t.true(optionNames.includes('--verbose')) + t.true(optionNames.includes('--progress')) + t.true(optionNames.includes('--debug')) +}) + +test('--port option has default value', (t) => { + const { command } = t.context + const portOption = command.options.find((opt) => opt.long === '--port') + + t.is(portOption.defaultValue, 8080, '--port should default to 8080') +}) diff --git a/packages/cli/src/commands/serve.test.js b/packages/cli/src/commands/serve.test.js new file mode 100644 index 000000000..0fb170c85 --- /dev/null +++ b/packages/cli/src/commands/serve.test.js @@ -0,0 +1,442 @@ +import test from 'ava' +import sinon from 'sinon' +import esmock from 'esmock' + +test.beforeEach((t) => { + t.context.sandbox = sinon.createSandbox() +}) + +test.afterEach.always((t) => { + t.context.sandbox.restore() +}) + +/** + * Helper to create mock paths object with getSitePath + */ +const createMockPaths = (projectRoot = '/test/project') => ({ + getProjectRoot: () => projectRoot, + getSitePath: () => `${projectRoot}/_site` +}) + +/** + * Helper to create mock reporter + */ +const createMockReporter = (sandbox) => ({ + configure: sandbox.stub().returnsThis(), + start: sandbox.stub().returnsThis(), + update: sandbox.stub().returnsThis(), + succeed: sandbox.stub().returnsThis(), + fail: sandbox.stub().returnsThis(), + info: sandbox.stub().returnsThis(), + detail: sandbox.stub().returnsThis(), + stop: sandbox.stub().returnsThis() +}) + +test('serve command should throw error when build output is missing', async (t) => { + const { sandbox } = t.context + + const ServeCommand = await esmock('./serve.js', { + '#lib/project/index.js': { + default: createMockPaths(), + hasSiteOutput: () => false + }, + '#lib/reporter/index.js': { + default: createMockReporter(sandbox) + }, + open: { + default: sandbox.stub() + } + }) + + const command = new ServeCommand() + command.name = sandbox.stub().returns('serve') + + const error = await t.throwsAsync(() => command.action({ port: 8080 }, command)) + + t.is(error.code, 'BUILD_OUTPUT_MISSING', 'should throw BUILD_OUTPUT_MISSING error') + t.regex(error.message, /build output not found/, 'error should mention build output not found') +}) + +test('serve command should run build first when --build flag is set and output missing', async (t) => { + const { sandbox } = t.context + + const mockBuild = sandbox.stub().resolves() + const mockReporter = createMockReporter(sandbox) + let buildCalled = false + + // Mock serve façade that rejects to end test + const mockServe = sandbox.stub().rejects(new Error('Test close')) + + const ServeCommand = await esmock('./serve.js', { + '#lib/project/index.js': { + default: createMockPaths(), + hasSiteOutput: () => { + // Return false first (before build), true after build + if (!buildCalled) return false + return true + } + }, + '#lib/11ty/index.js': { + default: { + build: async (opts) => { + buildCalled = true + return mockBuild(opts) + } + } + }, + '#lib/server/index.js': { + serve: mockServe + }, + '#lib/process/manager.js': { + default: { + onShutdown: sandbox.stub(), + onShutdownComplete: sandbox.stub() + } + }, + '#lib/reporter/index.js': { + default: mockReporter + }, + open: { + default: sandbox.stub() + } + }) + + const command = new ServeCommand() + command.name = sandbox.stub().returns('serve') + + // This will fail with our mock error, but build should have been called + await t.throwsAsync(() => command.action({ port: 8080, build: true, quiet: true }, command)) + + t.true(mockBuild.called, 'build should be called when --build flag is set') + t.true(mockReporter.start.called, 'reporter.start should be called for build') + t.true(mockReporter.succeed.called, 'reporter.succeed should be called after build') +}) + +test('serve command should not run build when --build flag not set', async (t) => { + const { sandbox } = t.context + + const mockBuild = sandbox.stub().resolves() + + const ServeCommand = await esmock('./serve.js', { + '#lib/project/index.js': { + default: createMockPaths(), + hasSiteOutput: () => false + }, + '#lib/11ty/index.js': { + default: { + build: mockBuild + } + }, + '#lib/reporter/index.js': { + default: createMockReporter(sandbox) + }, + open: { + default: sandbox.stub() + } + }) + + const command = new ServeCommand() + command.name = sandbox.stub().returns('serve') + + // Should throw because hasSiteOutput returns false and --build not set + await t.throwsAsync(() => command.action({ port: 8080 }, command)) + + t.false(mockBuild.called, 'build should not be called without --build flag') +}) + +test('serve command should throw error for invalid port', async (t) => { + const { sandbox } = t.context + + const ServeCommand = await esmock('./serve.js', { + '#lib/project/index.js': { + default: createMockPaths(), + hasSiteOutput: () => true + }, + '#lib/reporter/index.js': { + default: createMockReporter(sandbox) + }, + open: { + default: sandbox.stub() + } + }) + + const command = new ServeCommand() + command.name = sandbox.stub().returns('serve') + + const error = await t.throwsAsync(() => command.action({ port: 'invalid' }, command)) + t.regex(error.message, /Invalid port/, 'should throw error for invalid port') +}) + +test('serve command should throw error for out of range port', async (t) => { + const { sandbox } = t.context + + const ServeCommand = await esmock('./serve.js', { + '#lib/project/index.js': { + default: createMockPaths(), + hasSiteOutput: () => true + }, + '#lib/reporter/index.js': { + default: createMockReporter(sandbox) + }, + open: { + default: sandbox.stub() + } + }) + + const command = new ServeCommand() + command.name = sandbox.stub().returns('serve') + + const error = await t.throwsAsync(() => command.action({ port: 99999 }, command)) + t.regex(error.message, /Invalid port/, 'should throw error for out of range port') +}) + +test('serve command should register cleanup handler with processManager', async (t) => { + const { sandbox } = t.context + + const mockOnShutdown = sandbox.stub() + const mockOnShutdownComplete = sandbox.stub() + const mockStop = sandbox.stub().resolves() + + // Mock serve façade + const mockServe = sandbox.stub().resolves({ + url: 'http://localhost:8080', + stop: mockStop + }) + + const ServeCommand = await esmock('./serve.js', { + '#lib/project/index.js': { + default: createMockPaths(), + hasSiteOutput: () => true + }, + '#lib/server/index.js': { + serve: mockServe + }, + '#lib/process/manager.js': { + default: { + onShutdown: mockOnShutdown, + onShutdownComplete: mockOnShutdownComplete + } + }, + '#lib/reporter/index.js': { + default: createMockReporter(sandbox) + }, + open: { + default: sandbox.stub() + } + }) + + const command = new ServeCommand() + command.name = sandbox.stub().returns('serve') + + // Start the action but don't await it (it has infinite promise) + command.action({ port: 8080, quiet: true }, command) + + // Wait a tick + await new Promise(resolve => setImmediate(resolve)) + + t.true(mockOnShutdown.called, 'should register cleanup handler') + t.is(mockOnShutdown.firstCall.args[0], 'serve', 'should register with name "serve"') + t.is(mockOnShutdown.firstCall.args[1], mockStop, 'should pass stop function from façade') +}) + +test('serve command should handle port in use error', async (t) => { + const { sandbox } = t.context + + // Mock serve façade that rejects with port in use error + const mockServe = sandbox.stub().rejects(new Error('Port 8080 is already in use')) + + const ServeCommand = await esmock('./serve.js', { + '#lib/project/index.js': { + default: createMockPaths(), + hasSiteOutput: () => true + }, + '#lib/server/index.js': { + serve: mockServe + }, + '#lib/process/manager.js': { + default: { + onShutdown: sandbox.stub(), + onShutdownComplete: sandbox.stub() + } + }, + '#lib/reporter/index.js': { + default: createMockReporter(sandbox) + }, + open: { + default: sandbox.stub() + } + }) + + const command = new ServeCommand() + command.name = sandbox.stub().returns('serve') + + const error = await t.throwsAsync(() => command.action({ port: 8080, quiet: true }, command)) + t.regex(error.message, /Port 8080 is already in use/, 'should provide clear port in use message') +}) + +test('serve command should call open when --open flag is provided', async (t) => { + const { sandbox } = t.context + + const mockOpen = sandbox.stub() + + // Mock serve façade + const mockServe = sandbox.stub().resolves({ + url: 'http://localhost:8080', + stop: sandbox.stub().resolves() + }) + + const ServeCommand = await esmock('./serve.js', { + '#lib/project/index.js': { + default: createMockPaths(), + hasSiteOutput: () => true + }, + '#lib/server/index.js': { + serve: mockServe + }, + '#lib/process/manager.js': { + default: { + onShutdown: sandbox.stub(), + onShutdownComplete: sandbox.stub() + } + }, + '#lib/reporter/index.js': { + default: createMockReporter(sandbox) + }, + open: { + default: mockOpen + } + }) + + const command = new ServeCommand() + command.name = sandbox.stub().returns('serve') + + // Start the action but don't await it (it has infinite promise) + command.action({ port: 8080, open: true, quiet: true }, command) + + // Wait a tick for the start to complete + await new Promise(resolve => setImmediate(resolve)) + + t.true(mockOpen.called, 'open should be called when --open flag is provided') + t.is(mockOpen.firstCall.args[0], 'http://localhost:8080', 'should open correct URL') +}) + +test('serve command should not call open when --open flag is not provided', async (t) => { + const { sandbox } = t.context + + const mockOpen = sandbox.stub() + + // Mock serve façade + const mockServe = sandbox.stub().resolves({ + url: 'http://localhost:8080', + stop: sandbox.stub().resolves() + }) + + const ServeCommand = await esmock('./serve.js', { + '#lib/project/index.js': { + default: createMockPaths(), + hasSiteOutput: () => true + }, + '#lib/server/index.js': { + serve: mockServe + }, + '#lib/process/manager.js': { + default: { + onShutdown: sandbox.stub(), + onShutdownComplete: sandbox.stub() + } + }, + '#lib/reporter/index.js': { + default: createMockReporter(sandbox) + }, + open: { + default: mockOpen + } + }) + + const command = new ServeCommand() + command.name = sandbox.stub().returns('serve') + + // Start the action but don't await it (it has infinite promise) + command.action({ port: 8080, quiet: true }, command) + + // Wait a tick for the start to complete + await new Promise(resolve => setImmediate(resolve)) + + t.false(mockOpen.called, 'open should not be called without --open flag') +}) + +test('serve command should call serve façade with correct arguments', async (t) => { + const { sandbox } = t.context + + const mockServe = sandbox.stub().resolves({ + url: 'http://localhost:3000', + stop: sandbox.stub().resolves() + }) + + const ServeCommand = await esmock('./serve.js', { + '#lib/project/index.js': { + default: createMockPaths('/my/project'), + hasSiteOutput: () => true + }, + '#lib/server/index.js': { + serve: mockServe + }, + '#lib/process/manager.js': { + default: { + onShutdown: sandbox.stub(), + onShutdownComplete: sandbox.stub() + } + }, + '#lib/reporter/index.js': { + default: createMockReporter(sandbox) + }, + open: { + default: sandbox.stub() + } + }) + + const command = new ServeCommand() + command.name = sandbox.stub().returns('serve') + + // Start the action but don't await it + command.action({ port: 3000, quiet: true, verbose: false }, command) + + // Wait a tick + await new Promise(resolve => setImmediate(resolve)) + + t.true(mockServe.called, 'serve façade should be called') + t.is(mockServe.firstCall.args[0], '/my/project/_site', 'should pass getSitePath() result') + t.deepEqual(mockServe.firstCall.args[1], { port: 3000, quiet: true, verbose: false }, 'should pass options including verbose') +}) + +test('serve command should configure reporter with quiet and verbose options', async (t) => { + const { sandbox } = t.context + + const mockReporter = createMockReporter(sandbox) + + const ServeCommand = await esmock('./serve.js', { + '#lib/project/index.js': { + default: createMockPaths(), + hasSiteOutput: () => false + }, + '#lib/reporter/index.js': { + default: mockReporter + }, + open: { + default: sandbox.stub() + } + }) + + const command = new ServeCommand() + command.name = sandbox.stub().returns('serve') + + // Will throw because no build output, but reporter should still be configured + await t.throwsAsync(() => command.action({ port: 8080, quiet: true, verbose: true }, command)) + + t.true(mockReporter.configure.called, 'reporter.configure should be called') + t.deepEqual( + mockReporter.configure.firstCall.args[0], + { quiet: true, verbose: true }, + 'should pass quiet and verbose options to reporter' + ) +}) diff --git a/packages/cli/src/lib/project/paths.js b/packages/cli/src/lib/project/paths.js index e53fe285f..41ed52562 100644 --- a/packages/cli/src/lib/project/paths.js +++ b/packages/cli/src/lib/project/paths.js @@ -170,6 +170,14 @@ class Paths { return process.cwd() } + /** + * Get the absolute path to built site output directory + * @returns {string} Absolute path to _site directory + */ + getSitePath() { + return path.join(this.getProjectRoot(), '_site') + } + // ───────────────────────────────────────────────────────────────────────── // Relative directory names (method names ends with Dir) // ───────────────────────────────────────────────────────────────────────── diff --git a/packages/cli/src/lib/server/README.md b/packages/cli/src/lib/server/README.md new file mode 100644 index 000000000..82b1bf765 --- /dev/null +++ b/packages/cli/src/lib/server/README.md @@ -0,0 +1,88 @@ +# Static File Server Module + +A lightweight static file server for serving built Quire publications locally. + +## Purpose + +This module provides a simple HTTP server for the `quire serve` command, allowing users to preview their built site (`_site/`) before deployment. + +## Usage + +```javascript +import { serve } from '#lib/server/index.js' + +const { url, stop } = await serve('/path/to/_site', { + port: 8080, + quiet: false +}) + +console.log(`Serving at ${url}`) + +// When done +await stop() +``` + +## Limitations + +**Do not use this server in production.** It is designed for local development and review only. + +Known limitations: + +| Limitation | Description | +|------------|-------------| +| No HTTPS | HTTP only; no TLS/SSL support | +| No cache headers | No Cache-Control, ETag, or Last-Modified headers | +| No compression | Files are served uncompressed (no gzip/brotli) | +| No directory listing | Returns 404 for directories without index.html | +| Single-threaded | No clustering or worker threads | +| Synchronous file reads | Blocks on `fs.readFileSync()` for simplicity | +| No logging | Only debug output via `DEBUG=quire:lib:server` | +| Basic MIME types | Supports a limited set of web asset types | + +For production deployments, use a proper web server (nginx, Apache) or static hosting service (Netlify, Vercel, GitHub Pages, et cetera). + +## Architecture + +``` +lib/server/ +├── index.js # Façade: serve() function +├── index.spec.js # Unit tests +├── mime-types.js # MIME type mappings +├── mime-types.spec.js +└── README.md # This file +``` + +The `server` module uses a **façade pattern** to hide implementation details. The current implementation uses Node.js built-in `node:http`, but can easily be replaced with another library, such as `serve-handler`, without changing the public API. + +## API + +### `serve(rootDir, options) => Promise<{ url, stop }>` + +Starts a static file server. + +**Parameters:** +- `rootDir` (string): Directory to serve files from +- `options.port` (number, default: 8080): Port to listen on +- `options.quiet` (boolean, default: false): Suppress console output + +**Returns:** +- `url` (string): Server URL (e.g., `http://localhost:8080`) +- `stop` (function): Async function to stop the server + +### MIME Type Utilities + +```javascript +import { getMimeType, MIME_TYPES, DEFAULT_MIME_TYPE } from '#lib/server/index.js' + +getMimeType('/path/to/file.html') // 'text/html' +getMimeType('/path/to/unknown') // 'application/octet-stream' +``` + +## Security + +The server includes basic protection against directory traversal attacks by verifying that resolved file paths remain within the root directory. + +## Related + +- [ADR: Static File Server](../../docs/architecture-decisions/static-file-server.md) - Decision record for this implementation +- [Workflows: Serving the Built Site](../../docs/workflows.md#serving-the-built-site) - User documentation diff --git a/packages/cli/src/lib/server/index.js b/packages/cli/src/lib/server/index.js new file mode 100644 index 000000000..54edc6397 --- /dev/null +++ b/packages/cli/src/lib/server/index.js @@ -0,0 +1,162 @@ +/** + * Static file server façade + * + * Provides a simple interface for serving static files. The implementation + * is hidden behind a façade to allow easy swapping between: + * - Custom Node.js HTTP server (current) + * - serve-handler (Vercel) - future option + * - http-server - future option + * + * @example + * import { serve } from '#lib/server/index.js' + * + * const { url, stop } = await serve('/path/to/_site', { port: 8080 }) + * console.log(`Server running at ${url}`) + * // ... when done + * await stop() + * + * @module lib/server + */ +import http from 'node:http' +import fs from 'node:fs' +import path from 'node:path' +import reporter from '#lib/reporter/index.js' +import createDebug from '#debug' +import { getMimeType, MIME_TYPES, DEFAULT_MIME_TYPE } from './mime-types.js' + +// Re-export MIME type utilities for consumers +export { getMimeType, MIME_TYPES, DEFAULT_MIME_TYPE } + +const debug = createDebug('lib:server') + +/** + * Handle incoming HTTP requestuest + * @param {string} rootDir - Root directory to serve files from + * @param {http.IncomingMessage} request - requestuest object + * @param {http.Serverresponseponse} response - responseponse object + */ +function handlerequestuest(rootDir, request, response) { + let urlPath = request.url + + // Parse URL to handle query strings + try { + urlPath = new URL(request.url, `http://${request.headers.host}`).pathname + } catch { + // Fall back to raw URL if parsing fails + } + + // Decode URL-encoded characters + urlPath = decodeURIComponent(urlPath) + + // responseolve the file path + let filePath = path.join(rootDir, urlPath) + + // Security: prevent directory traversal + if (!filePath.startsWith(rootDir)) { + response.writeHead(403) + response.end('Forbidden') + return + } + + // Check if path exists + if (!fs.existsSync(filePath)) { + response.writeHead(404) + response.end('Not Found') + return + } + + const stat = fs.statSync(filePath) + + // Handle directory requestuests + if (stat.isDirectory()) { + // Try to serve index.html from the directory + const indexPath = path.join(filePath, 'index.html') + if (fs.existsSync(indexPath)) { + filePath = indexPath + } else { + response.writeHead(404) + response.end('Not Found') + return + } + } + + // Read and serve the file + const mimeType = getMimeType(filePath) + const content = fs.readFileSync(filePath) + + response.writeHead(200, { + 'Content-Type': mimeType, + 'Content-Length': content.length, + }) + response.end(content) + + debug('%s %s', request.method, urlPath) +} + +/** + * Start a static file server + * + * This is the main façade function. Implementation details are hidden, + * allowing the underlying server to be swapped (e.g., to serve-handler) + * without changing the command interface. + * + * Nota bene: Uses reporter.info() instead of spinner because the server + * runs indefinitely - a spinner would never complete. The info style + * provides a consistent look with other CLI output. + * + * @param {string} rootDir - Directory to serve files from + * @param {Object} options - Server options + * @param {number} [options.port=8080] - Port to listen on + * @param {boolean} [options.quiet=false] - Suppress output + * @param {boolean} [options.verbose=false] - Show detailed output + * @returns {Promise<{ url: string, stop: () => Promise }>} + */ +export async function serve(rootDir, options = {}) { + const port = options.port || 8080 + + return new Promise((resolve, reject) => { + const server = http.createServer((request, response) => { + handlerequestuest(rootDir, request, response) + }) + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + reject(new Error(`Port ${port} is already in use`)) + } else { + reject(err) + } + }) + + server.listen(port, () => { + const url = `http://localhost:${port}` + + if (!options.quiet) { + reporter.info(`Serving site at ${url}`) + reporter.info('Press Ctrl+C to stop') + } + + // Show additional details in verbose mode + if (options.verbose) { + reporter.detail(`Root: ${rootDir}`) + } + + debug('server started on port %d', port) + + // Return façade interface + resolve({ + url, + stop: () => new Promise((resolveStop) => { + server.close(() => { + if (!options.quiet) { + reporter.info('Server stopped') + } + debug('server stopped') + resolveStop() + }) + }) + }) + }) + }) +} + +export default { serve } diff --git a/packages/cli/src/lib/server/index.spec.js b/packages/cli/src/lib/server/index.spec.js new file mode 100644 index 000000000..b1d2cde6c --- /dev/null +++ b/packages/cli/src/lib/server/index.spec.js @@ -0,0 +1,34 @@ +import test from 'ava' +import { serve, getMimeType, MIME_TYPES, DEFAULT_MIME_TYPE } from './index.js' + +/** + * Re-export tests - verify index.js re-exports from mime-types.js + */ +test('index re-exports getMimeType from mime-types module', (t) => { + t.is(typeof getMimeType, 'function') + t.is(getMimeType('/test.html'), 'text/html') +}) + +test('index re-exports MIME_TYPES from mime-types module', (t) => { + t.is(typeof MIME_TYPES, 'object') + t.true('.html' in MIME_TYPES) +}) + +test('index re-exports DEFAULT_MIME_TYPE from mime-types module', (t) => { + t.is(DEFAULT_MIME_TYPE, 'application/octet-stream') +}) + +/** + * serve façade function tests + */ +test('serve is a function', (t) => { + t.is(typeof serve, 'function') +}) + +test('serve function accepts rootDir and optional options', (t) => { + // We can't actually start the server in unit tests without mocking, + // but we can verify the function signature + t.is(typeof serve, 'function') + // Function.length counts only required params (options has default) + t.is(serve.length, 1) +}) diff --git a/packages/cli/src/lib/server/mime-types.js b/packages/cli/src/lib/server/mime-types.js new file mode 100644 index 000000000..bf6f8a14d --- /dev/null +++ b/packages/cli/src/lib/server/mime-types.js @@ -0,0 +1,65 @@ +/** + * MIME type mappings for common web assets + * + * Used by the static file server to set correct Content-Type headers. + * + * @module lib/server/mime-types + */ +import path from 'node:path' + +/** + * MIME types for common web assets + */ +export const MIME_TYPES = { + // HTML/Text + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.mjs': 'application/javascript', + '.json': 'application/json', + '.xml': 'application/xml', + '.txt': 'text/plain', + + // Images + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.avif': 'image/avif', + + // Fonts + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf', + '.eot': 'application/vnd.ms-fontobject', + + // Documents + '.pdf': 'application/pdf', + '.epub': 'application/epub+zip', + + // Media + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.ogg': 'audio/ogg', + '.wav': 'audio/wav', +} + +/** + * Default MIME type for unknown file extensions + */ +export const DEFAULT_MIME_TYPE = 'application/octet-stream' + +/** + * Get MIME type for a file based on extension + * @param {string} filePath - Path to file + * @returns {string} MIME type + */ +export function getMimeType(filePath) { + const ext = path.extname(filePath).toLowerCase() + return MIME_TYPES[ext] || DEFAULT_MIME_TYPE +} diff --git a/packages/cli/src/lib/server/mime-types.spec.js b/packages/cli/src/lib/server/mime-types.spec.js new file mode 100644 index 000000000..e5a67bdaf --- /dev/null +++ b/packages/cli/src/lib/server/mime-types.spec.js @@ -0,0 +1,99 @@ +import test from 'ava' +import { getMimeType, MIME_TYPES, DEFAULT_MIME_TYPE } from './mime-types.js' + +/** + * MIME type tests + */ +test('getMimeType returns correct type for HTML', (t) => { + t.is(getMimeType('/path/to/file.html'), 'text/html') +}) + +test('getMimeType returns correct type for CSS', (t) => { + t.is(getMimeType('/path/to/style.css'), 'text/css') +}) + +test('getMimeType returns correct type for JavaScript', (t) => { + t.is(getMimeType('/path/to/script.js'), 'application/javascript') + t.is(getMimeType('/path/to/module.mjs'), 'application/javascript') +}) + +test('getMimeType returns correct type for JSON', (t) => { + t.is(getMimeType('/path/to/data.json'), 'application/json') +}) + +test('getMimeType returns correct type for XML', (t) => { + t.is(getMimeType('/path/to/feed.xml'), 'application/xml') +}) + +test('getMimeType returns correct type for images', (t) => { + t.is(getMimeType('/path/to/image.png'), 'image/png') + t.is(getMimeType('/path/to/image.jpg'), 'image/jpeg') + t.is(getMimeType('/path/to/image.jpeg'), 'image/jpeg') + t.is(getMimeType('/path/to/image.gif'), 'image/gif') + t.is(getMimeType('/path/to/image.svg'), 'image/svg+xml') + t.is(getMimeType('/path/to/image.webp'), 'image/webp') + t.is(getMimeType('/path/to/favicon.ico'), 'image/x-icon') + t.is(getMimeType('/path/to/image.avif'), 'image/avif') +}) + +test('getMimeType returns correct type for fonts', (t) => { + t.is(getMimeType('/path/to/font.woff'), 'font/woff') + t.is(getMimeType('/path/to/font.woff2'), 'font/woff2') + t.is(getMimeType('/path/to/font.ttf'), 'font/ttf') + t.is(getMimeType('/path/to/font.otf'), 'font/otf') + t.is(getMimeType('/path/to/font.eot'), 'application/vnd.ms-fontobject') +}) + +test('getMimeType returns correct type for documents', (t) => { + t.is(getMimeType('/path/to/doc.pdf'), 'application/pdf') + t.is(getMimeType('/path/to/book.epub'), 'application/epub+zip') +}) + +test('getMimeType returns correct type for media', (t) => { + t.is(getMimeType('/path/to/audio.mp3'), 'audio/mpeg') + t.is(getMimeType('/path/to/video.mp4'), 'video/mp4') + t.is(getMimeType('/path/to/video.webm'), 'video/webm') + t.is(getMimeType('/path/to/audio.ogg'), 'audio/ogg') + t.is(getMimeType('/path/to/audio.wav'), 'audio/wav') +}) + +test('getMimeType returns DEFAULT_MIME_TYPE for unknown extensions', (t) => { + t.is(getMimeType('/path/to/file.xyz'), DEFAULT_MIME_TYPE) + t.is(getMimeType('/path/to/file'), DEFAULT_MIME_TYPE) + t.is(getMimeType('/path/to/.hidden'), DEFAULT_MIME_TYPE) +}) + +test('getMimeType is case insensitive', (t) => { + t.is(getMimeType('/path/to/file.HTML'), 'text/html') + t.is(getMimeType('/path/to/file.CSS'), 'text/css') + t.is(getMimeType('/path/to/file.JS'), 'application/javascript') + t.is(getMimeType('/path/to/file.PNG'), 'image/png') +}) + +/** + * MIME_TYPES constant tests + */ +test('MIME_TYPES includes common web file types', (t) => { + t.true('.html' in MIME_TYPES) + t.true('.css' in MIME_TYPES) + t.true('.js' in MIME_TYPES) + t.true('.json' in MIME_TYPES) + t.true('.png' in MIME_TYPES) + t.true('.jpg' in MIME_TYPES) + t.true('.svg' in MIME_TYPES) + t.true('.woff2' in MIME_TYPES) + t.true('.pdf' in MIME_TYPES) +}) + +test('MIME_TYPES values are valid MIME type strings', (t) => { + for (const [ext, mimeType] of Object.entries(MIME_TYPES)) { + t.regex(mimeType, /^[a-z]+\/[a-z0-9.+-]+$/, `${ext} should have valid MIME type`) + } +}) + +/** + * DEFAULT_MIME_TYPE constant tests + */ +test('DEFAULT_MIME_TYPE is application/octet-stream', (t) => { + t.is(DEFAULT_MIME_TYPE, 'application/octet-stream') +})