You've already forked hugo-extended
mirror of
https://github.com/jakejarvis/hugo-extended.git
synced 2026-06-12 08:45:27 -04:00
refactor: full typescript migration (#174)
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
versioning-strategy: increase
|
||||
schedule:
|
||||
interval: daily
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Publish to NPM
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -14,11 +14,20 @@ jobs:
|
||||
name: Publish to NPM
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- run: npm install -g npm@latest
|
||||
- run: npm ci
|
||||
- run: npm publish
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Install dependencies
|
||||
run: npm install -g npm@latest && npm ci
|
||||
- name: Generate types
|
||||
run: npm run generate-types
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
- name: Type check
|
||||
run: npm run typecheck
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Publish
|
||||
run: npm publish
|
||||
|
||||
+51
-24
@@ -1,35 +1,62 @@
|
||||
name: Run tests
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
name: Test on ${{ matrix.os }} with Node ${{ matrix.node }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
node:
|
||||
- 24
|
||||
- 22
|
||||
- 20
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Node ${{ matrix.node }} on ${{ matrix.os }}
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
node: ["20", "22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: npm ci
|
||||
- run: npm audit --omit=dev
|
||||
continue-on-error: true
|
||||
- run: npm run test
|
||||
- run: node lib/cli.js new site mysite
|
||||
- run: node lib/cli.js --source mysite/ --minify --enableGitInfo --logLevel info
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "npm"
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Generate types
|
||||
run: npm run generate-types
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
- name: Type check
|
||||
run: npm run typecheck
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Run unit tests
|
||||
run: npm run test:unit
|
||||
- name: Run integration tests
|
||||
run: npm run test:integration
|
||||
|
||||
# coverage:
|
||||
# name: Coverage
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v6
|
||||
# - uses: actions/setup-node@v6
|
||||
# with:
|
||||
# node-version: "24"
|
||||
# cache: "npm"
|
||||
# - name: Install dependencies
|
||||
# run: npm ci
|
||||
# - name: Generate types
|
||||
# run: npm run generate-types
|
||||
# - name: Build
|
||||
# run: npm run build
|
||||
# - name: Run tests with coverage
|
||||
# run: npm run test:coverage
|
||||
# - name: Upload coverage reports
|
||||
# uses: codecov/codecov-action@v5
|
||||
# with:
|
||||
# files: ./coverage/coverage-final.json
|
||||
# fail_ci_if_error: false
|
||||
|
||||
+9
-2
@@ -1,3 +1,10 @@
|
||||
node_modules/
|
||||
vendor/
|
||||
npm-debug.log
|
||||
npm-debug.log*
|
||||
*.tsbuildinfo
|
||||
dist/
|
||||
bin/
|
||||
src/generated/
|
||||
mysite/
|
||||
coverage/
|
||||
.vitest-cache/
|
||||
*.test.mjs
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# AGENTS.md
|
||||
|
||||
Notes for LLM coding agents working on `hugo-extended`.
|
||||
|
||||
## What this repo is
|
||||
|
||||
`hugo-extended` is a **version-locked** Node package that wraps the Hugo CLI:
|
||||
|
||||
- **Package version == Hugo version** (e.g. `0.154.3`).
|
||||
- Provides:
|
||||
- **CLI passthrough** (`hugo` / `hugo-extended` binaries -> `dist/cli.mjs`)
|
||||
- **Programmatic API** (type-safe `exec`, `execWithOutput`, and builder-style `hugo.*`)
|
||||
- **Direct binary path access** (default export is callable and resolves to the Hugo binary path)
|
||||
|
||||
## Key files / mental model
|
||||
|
||||
- **Public API**: `src/hugo.ts`
|
||||
- `default export`: callable function that returns the Hugo binary path **and** has builder methods attached.
|
||||
- Named exports:
|
||||
- `getHugoBinary` (binary resolution + auto-install if missing)
|
||||
- `exec` / `execWithOutput` (spawn Hugo with argv built from options)
|
||||
- `hugo` (builder object)
|
||||
|
||||
- **CLI entry**: `src/cli.ts`
|
||||
- Resolves the binary path via the default export and forwards `process.argv.slice(2)` to Hugo.
|
||||
|
||||
- **Binary installation**: `src/lib/install.ts`
|
||||
- Downloads Hugo release assets and verifies SHA-256 checksums.
|
||||
- **macOS**: uses `sudo installer -pkg ... -target /` (and then symlinks `bin/hugo` -> `/usr/local/bin/hugo`).
|
||||
- **non-macOS**: extracts archive into `bin/` and `chmod +x`.
|
||||
|
||||
- **Postinstall**: `postinstall.js`
|
||||
- For published packages (where `dist/` exists), runs the compiled installer.
|
||||
- For repo/dev/CI (where `dist/` may not exist), exits successfully and skips installation.
|
||||
|
||||
- **Argv builder**: `src/lib/args.ts`
|
||||
- Builds argv using `src/generated/flags.json` to understand flag kinds and canonical long names.
|
||||
- Important: **when a flag exists in the generated spec, its long name is used as-is** (e.g. `--baseURL`, `--buildDrafts`).
|
||||
|
||||
- **Generated inputs** (committed):
|
||||
- `src/generated/types.ts`: command/options types.
|
||||
- `src/generated/flags.json`: runtime flag spec used by argv building.
|
||||
|
||||
## Code generation (types + flag spec)
|
||||
|
||||
`scripts/generate-types.ts`:
|
||||
|
||||
- Runs Hugo help output traversal (BFS across the command tree).
|
||||
- Emits:
|
||||
- `src/generated/types.ts`
|
||||
- `src/generated/flags.json`
|
||||
|
||||
When bumping Hugo versions, **regenerate these files** and expect downstream changes in:
|
||||
|
||||
- Flag names/casing (Hugo sometimes prefers mixed case like `baseURL`)
|
||||
- Which commands support which flags
|
||||
- Integration-test filesystem outputs (Hugo occasionally changes scaffolding)
|
||||
|
||||
## Testing (concise)
|
||||
|
||||
This repo uses **Vitest**.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm test # all tests (vitest run)
|
||||
npm run test:watch # watch mode
|
||||
npm run test:unit # unit tests only
|
||||
npm run test:integration # integration tests only (runs real Hugo)
|
||||
npm run test:coverage # coverage via v8
|
||||
```
|
||||
|
||||
### Test layout
|
||||
|
||||
- `tests/unit/*`
|
||||
- Fast, pure TS/JS (no Hugo execution).
|
||||
- Example: `tests/unit/args.test.ts` covers argv building behavior driven by `flags.json`.
|
||||
- Example: `tests/unit/types.test.ts` uses `expectTypeOf` to validate type surfaces.
|
||||
|
||||
- `tests/integration/*`
|
||||
- Executes real Hugo commands and does real filesystem work in temp dirs.
|
||||
- **Avoid `process.chdir()`** in tests: Vitest worker contexts may not support it.
|
||||
- Prefer passing Hugo’s global `--source` via `{ source: sitePath }`.
|
||||
|
||||
### Integration test expectations to keep in mind
|
||||
|
||||
- Hugo output is noisy (e.g. “Congratulations! Your new Hugo site…”). Tests should assert on filesystem results instead of brittle stdout text.
|
||||
- Hugo scaffolding changes over time:
|
||||
- Example: `hugo new theme` in 0.154.x generates a theme skeleton with `hugo.toml` / `hugo.yaml` rather than `theme.toml`.
|
||||
- Some flags may exist but not behave as you’d intuit for a given command:
|
||||
- Example: `hugo new site --force` does **not** overwrite an existing `hugo.toml` in 0.154.x.
|
||||
|
||||
## Practical tips for agents making changes
|
||||
|
||||
- If you touch argv generation (`src/lib/args.ts`):
|
||||
- Re-run `npm run generate-types` if the change depends on spec shape.
|
||||
- Prefer making tests match **the committed generated spec**, not an assumed kebab-case transform.
|
||||
|
||||
- If you touch installation (`src/lib/install.ts` / `postinstall.js`):
|
||||
- macOS install path uses `sudo installer` and will behave differently in CI/sandboxed environments.
|
||||
- Tests are intentionally focused on the wrapper behavior, not on end-to-end installer reliability.
|
||||
|
||||
- If you touch exports in `src/hugo.ts`:
|
||||
- Remember: consumers rely on the **default export being callable** (binary path) and having builder methods attached.
|
||||
|
||||
@@ -1,106 +1,231 @@
|
||||
# <img src="https://raw.githubusercontent.com/gohugoio/gohugoioTheme/master/static/images/hugo-logo-wide.svg?sanitize=true" alt="Hugo" width="115"> via NPM [](https://www.npmjs.com/package/hugo-extended) [](https://github.com/jakejarvis/hugo-extended/actions)
|
||||
# <img src="https://raw.githubusercontent.com/gohugoio/gohugoioTheme/master/static/images/hugo-logo-wide.svg?sanitize=true" alt="Hugo" width="115"> via NPM
|
||||
|
||||
> Plug-and-play binary wrapper for [Hugo Extended](https://gohugo.io/), the awesomest static-site generator.
|
||||
[](https://www.npmjs.com/package/hugo-extended)
|
||||
[](https://www.npmjs.com/package/hugo-extended)
|
||||
[](https://github.com/jakejarvis/hugo-extended/actions)
|
||||
|
||||
> Plug-and-play binary wrapper for [Hugo Extended](https://gohugo.io/), the awesomest static-site generator. Now with full TypeScript support and type-safe APIs!
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 **Zero configuration** — Hugo binary is automatically downloaded on install
|
||||
- 📦 **Version-locked** — Package version matches Hugo version (e.g., `hugo-extended@0.140.0` = Hugo v0.140.0)
|
||||
- 🔒 **Type-safe API** — Full TypeScript support with autocomplete for all Hugo commands and flags
|
||||
- ⚡ **Multiple APIs** — Use CLI, function-based, or builder-style APIs
|
||||
- 🎯 **Extended by default** — Automatically uses Hugo Extended on supported platforms
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
npm install hugo-extended --save-dev
|
||||
# or...
|
||||
# or
|
||||
yarn add hugo-extended --dev
|
||||
# or
|
||||
pnpm add hugo-extended --save-dev
|
||||
```
|
||||
|
||||
`hugo-extended` defaults to the [extended version](https://gohugo.io/troubleshooting/faq/#i-get--this-feature-is-not-available-in-your-current-hugo-version) of Hugo on [supported platforms](https://github.com/gohugoio/hugo/releases), and automatically falls back to vanilla Hugo if unsupported (mainly on 32-bit systems).
|
||||
### SCSS/PostCSS Support
|
||||
|
||||
This package's version numbers align with Hugo's — `hugo-extended@0.64.1` installs Hugo v0.64.1, for example.
|
||||
|
||||
_Note:_ If you'll be using the SCSS features of Hugo Extended, it's probably smart to install [`postcss`](https://www.npmjs.com/package/postcss), [`postcss-cli`](https://www.npmjs.com/package/postcss-cli), and [`autoprefixer`](https://www.npmjs.com/package/autoprefixer) as devDependencies too, since they can be conveniently called via [built-in Hugo pipes](https://gohugo.io/hugo-pipes/postcss/):
|
||||
If you're using Hugo's SCSS features, you'll also want:
|
||||
|
||||
```sh
|
||||
npm install postcss postcss-cli autoprefixer --save-dev
|
||||
# or...
|
||||
yarn add postcss postcss-cli autoprefixer --dev
|
||||
```
|
||||
|
||||
These integrate seamlessly with Hugo's [built-in PostCSS pipes](https://gohugo.io/functions/css/postcss/).
|
||||
|
||||
## Usage
|
||||
|
||||
The following examples simply refer to downloading and executing Hugo as a Node dependency. See the [official Hugo docs](https://gohugo.io/documentation/) for guidance on actual Hugo usage.
|
||||
### CLI Usage
|
||||
|
||||
### via CLI / `package.json`:
|
||||
|
||||
The `build:preview` script below is designed for [Netlify deploy previews](https://www.netlify.com/blog/2016/07/20/introducing-deploy-previews-in-netlify/), where [`$DEPLOY_PRIME_URL`](https://docs.netlify.com/configure-builds/environment-variables/#deploy-urls-and-metadata) is substituted for the base URL (usually ending in .netlify.app) of each pull request, branch, or commit preview.
|
||||
The simplest way — just run `hugo` commands directly:
|
||||
|
||||
```jsonc
|
||||
// package.json:
|
||||
|
||||
// package.json
|
||||
{
|
||||
// ...
|
||||
"scripts": {
|
||||
"build": "hugo",
|
||||
"build:preview": "hugo --baseURL \"${DEPLOY_PRIME_URL:-/}\" --buildDrafts --buildFuture",
|
||||
"start": "hugo server"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.3.4",
|
||||
"hugo-extended": "^0.88.1",
|
||||
"postcss": "^8.3.6",
|
||||
"postcss-cli": "^8.3.1"
|
||||
"dev": "hugo server --buildDrafts",
|
||||
"build": "hugo --minify",
|
||||
"build:preview": "hugo --baseURL \"${DEPLOY_PRIME_URL:-/}\" --buildDrafts --buildFuture"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Programmatic API
|
||||
|
||||
#### Builder-style API
|
||||
|
||||
A fluent interface where each Hugo command is a method:
|
||||
|
||||
```typescript
|
||||
import hugo from "hugo-extended";
|
||||
|
||||
// Start server
|
||||
await hugo.server({
|
||||
port: 1313,
|
||||
buildDrafts: true,
|
||||
});
|
||||
|
||||
// Build site
|
||||
await hugo.build({
|
||||
minify: true,
|
||||
environment: "production",
|
||||
});
|
||||
|
||||
// Module commands
|
||||
await hugo.mod.get();
|
||||
await hugo.mod.tidy();
|
||||
await hugo.mod.clean({ all: true });
|
||||
|
||||
// Generate shell completions
|
||||
await hugo.completion.zsh();
|
||||
```
|
||||
|
||||
#### Function-based API
|
||||
|
||||
Use `exec()` for commands that output to the console, or `execWithOutput()` to capture the output:
|
||||
|
||||
```typescript
|
||||
import { exec, execWithOutput } from "hugo-extended";
|
||||
|
||||
// Start development server with full type safety
|
||||
await exec("server", {
|
||||
port: 1313,
|
||||
buildDrafts: true,
|
||||
navigateToChanged: true,
|
||||
});
|
||||
|
||||
// Build for production
|
||||
await exec("build", {
|
||||
minify: true,
|
||||
cleanDestinationDir: true,
|
||||
baseURL: "https://example.com",
|
||||
});
|
||||
|
||||
// Capture command output
|
||||
const { stdout } = await execWithOutput("version");
|
||||
console.log(stdout); // "hugo v0.140.0+extended darwin/arm64 ..."
|
||||
|
||||
// List all content pages
|
||||
const { stdout: pages } = await execWithOutput("list all");
|
||||
```
|
||||
|
||||
#### Direct Binary Access
|
||||
|
||||
For advanced use cases, get the Hugo binary path directly:
|
||||
|
||||
```typescript
|
||||
import hugo from "hugo-extended";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
const binPath = await hugo();
|
||||
console.log(binPath); // "/usr/local/bin/hugo" or similar
|
||||
|
||||
// Use with spawn, exec, or any process library
|
||||
spawn(binPath, ["version"], { stdio: "inherit" });
|
||||
```
|
||||
|
||||
### Type Imports
|
||||
|
||||
Import Hugo types for use in your own code:
|
||||
|
||||
```typescript
|
||||
import type { HugoCommand, HugoOptionsFor, HugoServerOptions } from "hugo-extended";
|
||||
|
||||
// Type-safe option objects
|
||||
const serverOpts: HugoServerOptions = {
|
||||
port: 1313,
|
||||
buildDrafts: true,
|
||||
disableLiveReload: false,
|
||||
};
|
||||
|
||||
// Generic helper
|
||||
function runHugo<C extends HugoCommand>(cmd: C, opts: HugoOptionsFor<C>) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
$ npm run start
|
||||
## API Reference
|
||||
|
||||
Start building sites …
|
||||
hugo v0.88.1-5BC54738+extended darwin/amd64 BuildDate=2021-09-04T09:39:19Z VendorInfo=gohugoio
|
||||
### `exec(command, options?)`
|
||||
|
||||
| EN
|
||||
-------------------+------
|
||||
Pages | 50
|
||||
Paginator pages | 0
|
||||
Non-page files | 138
|
||||
Static files | 39
|
||||
Processed images | 63
|
||||
Aliases | 0
|
||||
Sitemaps | 1
|
||||
Cleaned | 0
|
||||
Execute a Hugo command with inherited stdio (output goes to console).
|
||||
|
||||
Built in 2361 ms
|
||||
Watching for changes in {archetypes,assets,content,data,layouts,package.json,static}
|
||||
Watching for config changes in config.toml
|
||||
Environment: "development"
|
||||
Serving pages from memory
|
||||
Web Server is available at http://localhost:1313/ (bind address 127.0.0.1)
|
||||
- **command** — Hugo command string (e.g., `"server"`, `"build"`, `"mod clean"`)
|
||||
- **options** — Type-safe options object (optional)
|
||||
- **Returns** — `Promise<void>`
|
||||
|
||||
### `execWithOutput(command, options?)`
|
||||
|
||||
Execute a Hugo command and capture output.
|
||||
|
||||
- **command** — Hugo command string
|
||||
- **options** — Type-safe options object (optional)
|
||||
- **Returns** — `Promise<{ stdout: string; stderr: string }>`
|
||||
|
||||
### `hugo` (default export)
|
||||
|
||||
The default export is both callable (returns binary path) and has builder methods:
|
||||
|
||||
```typescript
|
||||
// Get binary path (backward compatible)
|
||||
const binPath = await hugo();
|
||||
|
||||
// Builder methods
|
||||
await hugo.build({ minify: true });
|
||||
await hugo.server({ port: 3000 });
|
||||
```
|
||||
|
||||
### via API:
|
||||
### Available Commands
|
||||
|
||||
```js
|
||||
// version.js:
|
||||
All Hugo commands are fully typed with autocomplete:
|
||||
|
||||
import hugo from "hugo-extended";
|
||||
import { execFile } from "child_process";
|
||||
| Command | Builder Method | Description |
|
||||
|---------|---------------|-------------|
|
||||
| `build` | `hugo.build()` | Build your site |
|
||||
| `server` | `hugo.server()` | Start dev server |
|
||||
| `new` | `hugo.new()` | Create new content |
|
||||
| `mod get` | `hugo.mod.get()` | Download modules |
|
||||
| `mod tidy` | `hugo.mod.tidy()` | Clean go.mod/go.sum |
|
||||
| `mod clean` | `hugo.mod.clean()` | Clean module cache |
|
||||
| `mod vendor` | `hugo.mod.vendor()` | Vendor dependencies |
|
||||
| `list all` | `hugo.list.all()` | List all content |
|
||||
| `list drafts` | `hugo.list.drafts()` | List draft content |
|
||||
| `config` | `hugo.config()` | Print configuration |
|
||||
| `version` | `hugo.version()` | Print version |
|
||||
| `env` | `hugo.env()` | Print environment |
|
||||
| ... | ... | [All Hugo commands supported](https://gohugo.io/commands/) |
|
||||
|
||||
(async () => {
|
||||
const binPath = await hugo();
|
||||
## Platform Support
|
||||
|
||||
execFile(binPath, ["version"], (error, stdout) => {
|
||||
console.log(stdout);
|
||||
});
|
||||
})();
|
||||
Hugo Extended is automatically used on supported platforms:
|
||||
|
||||
| Platform | Architecture | Hugo Extended |
|
||||
|----------|-------------|---------------|
|
||||
| macOS | x64, ARM64 | ✅ |
|
||||
| Linux | x64, ARM64 | ✅ |
|
||||
| Windows | x64 | ✅ |
|
||||
| Windows | ARM64 | ❌ (vanilla Hugo) |
|
||||
| FreeBSD | x64 | ❌ (vanilla Hugo) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hugo binary not found
|
||||
|
||||
If Hugo seems to disappear (rare edge case), it will be automatically reinstalled on next use. You can also manually trigger reinstallation:
|
||||
|
||||
```sh
|
||||
npm rebuild hugo-extended
|
||||
```
|
||||
|
||||
```bash
|
||||
$ node version.js
|
||||
hugo v0.88.1-5BC54738+extended darwin/amd64 BuildDate=2021-09-04T09:39:19Z VendorInfo=gohugoio
|
||||
```
|
||||
### Permission issues on macOS
|
||||
|
||||
## Examples
|
||||
|
||||
- [jakejarvis/jarv.is](https://github.com/jakejarvis/jarv.is)
|
||||
As of [v0.153.0](https://github.com/gohugoio/hugo/releases/tag/v0.153.0), Hugo is distributed as a full installer for macOS, rather than a simple binary/executable file. This package will make its best effort to run the installer for you (which includes prompting you for `sudo` access) but this method introduces infinitely more opportunities for things to go wrong. Please [open an issue](https://github.com/jakejarvis/hugo-extended/issues/new) if you encounter any issues.
|
||||
|
||||
## License
|
||||
|
||||
This project is distributed under the [MIT License](LICENSE.md). Hugo is distributed under the [Apache License 2.0](https://github.com/gohugoio/hugo/blob/master/LICENSE).
|
||||
This project is distributed under the [MIT License](LICENSE). Hugo is distributed under the [Apache License 2.0](https://github.com/gohugoio/hugo/blob/master/LICENSE).
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": ["**", "!src/generated", "!!**/dist"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"vendor/**",
|
||||
"*.d.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.es6,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"brace-style": "error",
|
||||
|
||||
camelcase: ["error", {
|
||||
properties: "never",
|
||||
ignoreDestructuring: true,
|
||||
}],
|
||||
|
||||
"comma-dangle": ["error", {
|
||||
arrays: "always-multiline",
|
||||
objects: "always-multiline",
|
||||
imports: "always-multiline",
|
||||
exports: "always-multiline",
|
||||
functions: "never",
|
||||
}],
|
||||
"comma-spacing": "error",
|
||||
"comma-style": "error",
|
||||
curly: ["error", "multi-line"],
|
||||
"func-call-spacing": "error",
|
||||
|
||||
"no-multiple-empty-lines": ["error", {
|
||||
max: 1,
|
||||
}],
|
||||
|
||||
"no-tabs": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"object-curly-spacing": ["error", "always"],
|
||||
|
||||
"one-var": ["error", {
|
||||
var: "never",
|
||||
let: "never",
|
||||
const: "never",
|
||||
}],
|
||||
|
||||
"operator-linebreak": ["error", "after", { overrides: { "?": "before", ":": "before" } }],
|
||||
"padded-blocks": ["error", "never"],
|
||||
"quote-props": ["error", "as-needed"],
|
||||
|
||||
quotes: ["error", "double", {
|
||||
avoidEscape: true,
|
||||
allowTemplateLiterals: true,
|
||||
}],
|
||||
|
||||
semi: "error",
|
||||
"semi-spacing": "error",
|
||||
"space-before-blocks": "error",
|
||||
|
||||
"space-before-function-paren": ["error", {
|
||||
named: "never",
|
||||
anonymous: "always",
|
||||
asyncArrow: "always",
|
||||
}],
|
||||
|
||||
"spaced-comment": ["error", "always", {
|
||||
line: {
|
||||
markers: ["/"],
|
||||
exceptions: ["-", "+"],
|
||||
},
|
||||
|
||||
block: {
|
||||
markers: ["!"],
|
||||
exceptions: ["*"],
|
||||
balanced: true,
|
||||
},
|
||||
}],
|
||||
|
||||
"template-tag-spacing": ["error", "never"],
|
||||
|
||||
"arrow-parens": ["error", "always"],
|
||||
|
||||
"arrow-spacing": ["error", {
|
||||
before: true,
|
||||
after: true,
|
||||
}],
|
||||
|
||||
"no-confusing-arrow": ["error", {
|
||||
allowParens: true,
|
||||
}],
|
||||
|
||||
"no-var": "error",
|
||||
|
||||
"prefer-const": ["error", {
|
||||
destructuring: "any",
|
||||
ignoreReadBeforeAssign: true,
|
||||
}],
|
||||
|
||||
"prefer-destructuring": ["error", {
|
||||
VariableDeclarator: {
|
||||
array: false,
|
||||
object: true,
|
||||
},
|
||||
|
||||
AssignmentExpression: {
|
||||
array: true,
|
||||
object: false,
|
||||
},
|
||||
}],
|
||||
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"template-curly-spacing": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/**/*.js"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.mocha,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
/**
|
||||
* @returns A promise of the absolute path to the Hugo executable (`hugo.exe` on
|
||||
* Windows, simply `hugo` otherwise) once it's installed.
|
||||
*/
|
||||
export default function hugo(): Promise<string>;
|
||||
@@ -1,22 +0,0 @@
|
||||
import logSymbols from "log-symbols";
|
||||
import install from "./lib/install.js";
|
||||
import { getBinPath, doesBinExist } from "./lib/utils.js";
|
||||
|
||||
const hugo = async () => {
|
||||
const bin = getBinPath();
|
||||
|
||||
// A fix for fleeting ENOENT errors, where Hugo seems to disappear. For now,
|
||||
// just reinstall Hugo when it's missing and then continue normally like
|
||||
// nothing happened.
|
||||
// See: https://github.com/jakejarvis/hugo-extended/issues/81
|
||||
if (!doesBinExist(bin)) {
|
||||
// Hugo isn't there for some reason. Try re-installing.
|
||||
console.info(`${logSymbols.info} Hugo is missing, reinstalling now...`);
|
||||
await install();
|
||||
}
|
||||
|
||||
return bin;
|
||||
};
|
||||
|
||||
// The only thing this module really exports is the absolute path to Hugo:
|
||||
export default hugo;
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import hugo from "../index.js";
|
||||
|
||||
(async () => {
|
||||
const args = process.argv.slice(2);
|
||||
const bin = await hugo();
|
||||
|
||||
spawn(bin, args, { stdio: "inherit" })
|
||||
.on("exit", (code) => {
|
||||
// forward Hugo's exit code so this module itself reports success/failure
|
||||
process.exitCode = code;
|
||||
});
|
||||
})();
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { spawnSync } from "child_process";
|
||||
import { pipeline } from "stream/promises";
|
||||
import crypto from "crypto";
|
||||
import * as tar from "tar";
|
||||
import AdmZip from "adm-zip";
|
||||
import logSymbols from "log-symbols";
|
||||
import {
|
||||
getPkgVersion,
|
||||
getReleaseUrl,
|
||||
getReleaseFilename,
|
||||
getBinFilename,
|
||||
getBinVersion,
|
||||
getChecksumFilename,
|
||||
isExtended,
|
||||
} from "./utils.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function downloadFile(url, dest) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${url}: ${response.statusText}`);
|
||||
}
|
||||
await pipeline(response.body, fs.createWriteStream(dest));
|
||||
}
|
||||
|
||||
async function verifyChecksum(filePath, checksumUrl, filename) {
|
||||
const response = await fetch(checksumUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download checksums: ${response.statusText}`);
|
||||
}
|
||||
const checksums = await response.text();
|
||||
|
||||
// checksums file format: "sha256 filename"
|
||||
const expectedChecksum = checksums
|
||||
.split("\n")
|
||||
.find((line) => line.endsWith(filename))
|
||||
?.split(/\s+/)[0];
|
||||
|
||||
if (!expectedChecksum) {
|
||||
throw new Error(`Checksum for ${filename} not found in checksums file.`);
|
||||
}
|
||||
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(fileBuffer);
|
||||
const actualChecksum = hash.digest("hex");
|
||||
|
||||
if (actualChecksum !== expectedChecksum) {
|
||||
throw new Error(`Checksum mismatch! Expected ${expectedChecksum}, got ${actualChecksum}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function install() {
|
||||
try {
|
||||
const version = getPkgVersion();
|
||||
const releaseFile = getReleaseFilename(version);
|
||||
const checksumFile = getChecksumFilename(version);
|
||||
const binFile = getBinFilename();
|
||||
|
||||
if (!releaseFile) {
|
||||
throw new Error(`Are you sure this platform is supported? See: https://github.com/gohugoio/hugo/releases/tag/v${version}`);
|
||||
}
|
||||
|
||||
if (!isExtended(releaseFile)) {
|
||||
console.warn(`${logSymbols.info} Hugo Extended isn't supported on this platform, downloading vanilla Hugo instead.`);
|
||||
}
|
||||
|
||||
// Prepare vendor directory
|
||||
const vendorDir = path.join(__dirname, "..", "vendor");
|
||||
if (!fs.existsSync(vendorDir)) {
|
||||
fs.mkdirSync(vendorDir, { recursive: true });
|
||||
}
|
||||
|
||||
const releaseUrl = getReleaseUrl(version, releaseFile);
|
||||
const checksumUrl = getReleaseUrl(version, checksumFile);
|
||||
const downloadPath = path.join(vendorDir, releaseFile);
|
||||
|
||||
console.info(`${logSymbols.info} Downloading ${releaseFile}...`);
|
||||
await downloadFile(releaseUrl, downloadPath);
|
||||
|
||||
console.info(`${logSymbols.info} Verifying checksum...`);
|
||||
await verifyChecksum(downloadPath, checksumUrl, releaseFile);
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
console.info(`${logSymbols.info} Installing ${releaseFile} (requires sudo)...`);
|
||||
// Run MacOS installer
|
||||
const result = spawnSync("sudo", ["installer", "-pkg", downloadPath, "-target", "/"], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Installer failed with exit code ${result.status}`);
|
||||
}
|
||||
|
||||
// Cleanup downloaded pkg
|
||||
fs.unlinkSync(downloadPath);
|
||||
} else {
|
||||
console.info(`${logSymbols.info} Extracting...`);
|
||||
|
||||
if (releaseFile.endsWith(".zip")) {
|
||||
const zip = new AdmZip(downloadPath);
|
||||
zip.extractAllTo(vendorDir, true);
|
||||
|
||||
// Cleanup zip
|
||||
fs.unlinkSync(downloadPath);
|
||||
} else if (releaseFile.endsWith(".tar.gz")) {
|
||||
await tar.x({
|
||||
file: downloadPath,
|
||||
cwd: vendorDir,
|
||||
});
|
||||
|
||||
// Cleanup tar.gz
|
||||
fs.unlinkSync(downloadPath);
|
||||
}
|
||||
|
||||
const binPath = path.join(vendorDir, binFile);
|
||||
if (fs.existsSync(binPath)) {
|
||||
fs.chmodSync(binPath, 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
console.info(`${logSymbols.success} Hugo installed successfully!`);
|
||||
|
||||
// Check version
|
||||
if (process.platform === "darwin") {
|
||||
console.info(getBinVersion("/usr/local/bin/hugo"));
|
||||
return "/usr/local/bin/hugo";
|
||||
} else {
|
||||
const binPath = path.join(vendorDir, binFile);
|
||||
console.info(getBinVersion(binPath));
|
||||
return binPath;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${logSymbols.error} Hugo installation failed. :(`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default install;
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { execFileSync } from "child_process";
|
||||
import { readPackageUpSync } from "read-package-up";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// This package's version number (should) always match the Hugo release we want.
|
||||
// We check for a `hugoVersion` field in package.json just in case it doesn't
|
||||
// match in the future (from pushing an emergency package update, etc.).
|
||||
export function getPkgVersion() {
|
||||
const { packageJson } = readPackageUpSync({ cwd: __dirname });
|
||||
return packageJson.hugoVersion || packageJson.version;
|
||||
}
|
||||
|
||||
// Generate the full GitHub URL to a given release file.
|
||||
export function getReleaseUrl(version, filename) {
|
||||
return `https://github.com/gohugoio/hugo/releases/download/v${version}/${filename}`;
|
||||
}
|
||||
|
||||
// Binary is named `hugo.exe` on Windows, and simply `hugo` otherwise.
|
||||
export function getBinFilename() {
|
||||
return process.platform === "win32" ? "hugo.exe" : "hugo";
|
||||
}
|
||||
|
||||
// Simple shortcut to ./vendor/hugo[.exe] from package root.
|
||||
export function getBinPath() {
|
||||
if (process.platform === "darwin") return "/usr/local/bin/hugo";
|
||||
|
||||
return path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"vendor",
|
||||
getBinFilename()
|
||||
);
|
||||
}
|
||||
|
||||
// Returns the output of the `hugo version` command, i.e.:
|
||||
// "hugo v0.88.1-5BC54738+extended darwin/arm64 BuildDate=..."
|
||||
export function getBinVersion(bin) {
|
||||
const stdout = execFileSync(bin, ["version"]);
|
||||
return stdout.toString().trim();
|
||||
}
|
||||
|
||||
// Simply detect if the given file exists.
|
||||
export function doesBinExist(bin) {
|
||||
try {
|
||||
if (fs.existsSync(bin)) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
// something bad happened besides Hugo not existing
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hugo Extended supports: macOS x64 / ARM64, Linux x64 / ARM64, Windows x64.
|
||||
// All other combos fall back to vanilla Hugo. There are surely much better ways
|
||||
// to do this but this is easy to read/update. :)
|
||||
export function getReleaseFilename(version) {
|
||||
const { platform, arch } = process;
|
||||
|
||||
const filename =
|
||||
// macOS: as of 0.102.0, binaries are universal
|
||||
platform === "darwin" && arch === "x64"
|
||||
? `hugo_extended_${version}_darwin-universal.pkg`
|
||||
: platform === "darwin" && arch === "arm64"
|
||||
? `hugo_extended_${version}_darwin-universal.pkg`
|
||||
|
||||
// Windows
|
||||
: platform === "win32" && arch === "x64"
|
||||
? `hugo_extended_${version}_windows-amd64.zip`
|
||||
: platform === "win32" && arch === "arm64"
|
||||
? `hugo_${version}_windows-arm64.zip`
|
||||
|
||||
// Linux
|
||||
: platform === "linux" && arch === "x64"
|
||||
? `hugo_extended_${version}_linux-amd64.tar.gz`
|
||||
: platform === "linux" && arch === "arm64"
|
||||
? `hugo_extended_${version}_linux-arm64.tar.gz`
|
||||
|
||||
// FreeBSD
|
||||
: platform === "freebsd" && arch === "x64"
|
||||
? `hugo_${version}_freebsd-amd64.tar.gz`
|
||||
|
||||
// OpenBSD
|
||||
: platform === "openbsd" && arch === "x64"
|
||||
? `hugo_${version}_openbsd-amd64.tar.gz`
|
||||
|
||||
// not gonna work :(
|
||||
: null;
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
// Simple formula for the checksums.txt file.
|
||||
export function getChecksumFilename(version) {
|
||||
return `hugo_${version}_checksums.txt`;
|
||||
}
|
||||
|
||||
// Check if Hugo extended is being downloaded (as opposed to plain Hugo) based on the release filename.
|
||||
export function isExtended(releaseFile) {
|
||||
return releaseFile.startsWith("hugo_extended_");
|
||||
}
|
||||
Generated
+2326
-2273
File diff suppressed because it is too large
Load Diff
+53
-27
@@ -3,48 +3,74 @@
|
||||
"version": "0.154.3",
|
||||
"description": "✏️ Plug-and-play binary wrapper for Hugo Extended, the awesomest static-site generator.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/jakejarvis/hugo-extended",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/jakejarvis/hugo-extended.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/jakejarvis/hugo-extended/issues"
|
||||
},
|
||||
"author": {
|
||||
"name": "Jake Jarvis",
|
||||
"email": "jake@jarv.is",
|
||||
"url": "https://github.com/jakejarvis"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/jakejarvis/hugo-extended.git"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
"postinstall.js",
|
||||
"lib"
|
||||
],
|
||||
"bin": {
|
||||
"hugo": "lib/cli.js",
|
||||
"hugo-extended": "lib/cli.js"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": "./index.js",
|
||||
"types": "./index.d.ts",
|
||||
"main": "./dist/hugo.mjs",
|
||||
"module": "./dist/hugo.mjs",
|
||||
"types": "./dist/hugo.d.mts",
|
||||
"bin": {
|
||||
"hugo": "dist/cli.mjs",
|
||||
"hugo-extended": "dist/cli.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/hugo.d.mts",
|
||||
"import": "./dist/hugo.mjs",
|
||||
"default": "./dist/hugo.mjs"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/generated/types.d.mts",
|
||||
"default": "./dist/generated/types.mjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"postinstall.js"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"generate-types": "tsx scripts/generate-types.ts",
|
||||
"lint": "biome check",
|
||||
"lint:fix": "biome check --write",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"postinstall": "node postinstall.js",
|
||||
"prepublishOnly": "npm run generate-types && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"log-symbols": "^7.0.1",
|
||||
"read-package-up": "^12.0.0",
|
||||
"tar": "^7.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.11",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"del": "^8.0.1",
|
||||
"eslint": "^9.39.2",
|
||||
"globals": "^16.5.0",
|
||||
"mocha": "^11.7.5"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node postinstall.js",
|
||||
"test": "eslint . && mocha"
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tsdown": "^0.19.0-beta.3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
|
||||
Regular → Executable
+37
-3
@@ -1,4 +1,38 @@
|
||||
import install from "./lib/install.js";
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Install Hugo right off the bat.
|
||||
(async () => await install())();
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Postinstall wrapper script that properly handles errors during Hugo binary installation.
|
||||
* This script imports and executes the install function, logging any errors with full stack traces
|
||||
* and exiting with a non-zero code on failure.
|
||||
*
|
||||
* During development/CI (before build), the dist folder won't exist and this script will exit gracefully.
|
||||
* For published packages, the dist folder is included and installation will proceed.
|
||||
*/
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const installPath = join(__dirname, "dist", "lib", "install.mjs");
|
||||
|
||||
async function run() {
|
||||
// Skip installation if dist folder doesn't exist (development/CI environment)
|
||||
if (!existsSync(installPath)) {
|
||||
console.log(
|
||||
"Skipping Hugo installation (dist not found - likely in CI or development environment)",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const m = await import("./dist/lib/install.mjs");
|
||||
await m.default();
|
||||
} catch (error) {
|
||||
console.error("Hugo installation failed:");
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"]
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { x } from "tinyexec";
|
||||
import hugo from "../src/hugo";
|
||||
|
||||
const OUT_DIR = "src/generated";
|
||||
const HUGO_TYPES_FILE = "types.ts";
|
||||
const HUGO_FLAGS_JSON_FILE = "flags.json";
|
||||
|
||||
/**
|
||||
* Normalized flag "kinds" that we map Hugo/Cobra type tokens into.
|
||||
* These directly correspond to TypeScript types we emit.
|
||||
*/
|
||||
type FlagKind = "boolean" | "string" | "number" | "string[]" | "number[]";
|
||||
|
||||
/**
|
||||
* Represents a single CLI flag as parsed from Hugo help output.
|
||||
*/
|
||||
type FlagSpec = {
|
||||
/** Long flag name, including the leading `--` (e.g. `--baseURL`). */
|
||||
long: string;
|
||||
/** Optional short flag name, including the leading `-` (e.g. `-b`). */
|
||||
short?: string;
|
||||
/**
|
||||
* Type token printed by Cobra/pflag (e.g. `string`, `int`, `strings`, `file`).
|
||||
* Omitted for many boolean flags.
|
||||
*/
|
||||
typeToken?: string;
|
||||
/** Derived TS-friendly kind for code generation + argv building. */
|
||||
kind: FlagKind;
|
||||
/** Human description (wrapped lines merged, defaults/enums stripped out). */
|
||||
description: string;
|
||||
/**
|
||||
* Enum values inferred from patterns like `(debug|info|warn|error)` in the description.
|
||||
* When present, we emit a string-literal union instead of a plain `string`.
|
||||
*/
|
||||
enum?: string[];
|
||||
/**
|
||||
* Default value parsed from a trailing `(default ...)` or `(default is ...)` suffix.
|
||||
* Stored as raw text, since Hugo prints defaults in multiple formats.
|
||||
*/
|
||||
defaultRaw?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a Hugo command’s parsed help metadata.
|
||||
*/
|
||||
type CommandSpec = {
|
||||
/**
|
||||
* Command tokens representing the "path" to the command.
|
||||
* Examples: `["server"]`, `["mod","get"]`.
|
||||
*/
|
||||
pathTokens: string[];
|
||||
/** Flags listed under the `Flags:` section (command-local). */
|
||||
flags: FlagSpec[];
|
||||
/** Flags listed under the `Global Flags:` section (persistent/global). */
|
||||
globalFlags: FlagSpec[];
|
||||
/** Subcommand names listed under the `Available Commands:` section. */
|
||||
subcommands: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Matches a single Hugo flag row in the `Flags:`/`Global Flags:` sections.
|
||||
* Examples:
|
||||
* - `-b, --baseURL string ...`
|
||||
* - ` --cacheDir string ...`
|
||||
* - `-D, --buildDrafts ...`
|
||||
*/
|
||||
const FLAG_LINE =
|
||||
/^\s*(?:(?<short>-[A-Za-z]),\s*)?(?<long>--[A-Za-z0-9][A-Za-z0-9-]*)\s*(?:(?<type>[A-Za-z][A-Za-z0-9]*)\s+)?(?<desc>.+?)\s*$/;
|
||||
|
||||
/**
|
||||
* Matches a wrapped continuation line for a flag description (indented; not starting with `-`/`--`).
|
||||
*/
|
||||
const CONTINUATION_LINE = /^\s{2,}(?<more>[^-\s].+?)\s*$/;
|
||||
|
||||
/**
|
||||
* Matches section headers like `Flags:` / `Global Flags:` / `Available Commands:`.
|
||||
*/
|
||||
const SECTION_HEADER = /^(?<name>[A-Z][A-Za-z ]+):\s*$/;
|
||||
|
||||
/**
|
||||
* Canonical type tokens emitted by Cobra/pflag in help output.
|
||||
* If a captured "type" isn't in this list, it's actually part of the description (common for booleans).
|
||||
*/
|
||||
const KNOWN_TYPE_TOKENS = new Set([
|
||||
"string",
|
||||
"strings",
|
||||
"int",
|
||||
"int64",
|
||||
"uint",
|
||||
"uint64",
|
||||
"float64",
|
||||
"bool",
|
||||
"boolean",
|
||||
"file",
|
||||
"duration",
|
||||
"ints",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Map Cobra/pflag type tokens to a small set of TS-friendly kinds.
|
||||
*
|
||||
* @param typeToken - Token printed in help output (e.g. `string`, `int`, `strings`, `file`).
|
||||
* @returns A normalized {@link FlagKind} used in code generation.
|
||||
*/
|
||||
function mapTypeTokenToKind(typeToken?: string): FlagKind {
|
||||
if (!typeToken) return "boolean";
|
||||
switch (typeToken.toLowerCase()) {
|
||||
case "bool":
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
case "string":
|
||||
case "file":
|
||||
case "duration":
|
||||
return "string";
|
||||
case "strings":
|
||||
return "string[]";
|
||||
case "int":
|
||||
case "int64":
|
||||
case "uint":
|
||||
case "uint64":
|
||||
case "float64":
|
||||
return "number";
|
||||
case "ints":
|
||||
return "number[]";
|
||||
default:
|
||||
// Be conservative: Hugo occasionally prints tokens beyond the common set.
|
||||
return "string";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a trailing default from a help description.
|
||||
*
|
||||
* Supports:
|
||||
* - `(default true)`
|
||||
* - `(default "127.0.0.1")`
|
||||
* - `(default is hugo.yaml|json|toml)`
|
||||
* - `(default -1)`
|
||||
*
|
||||
* @param desc - Full description string from help output.
|
||||
* @returns Cleaned description + raw default (if present).
|
||||
*/
|
||||
function extractDefault(desc: string): {
|
||||
cleaned: string;
|
||||
defaultRaw?: string;
|
||||
} {
|
||||
const re = /\s*\(default(?:\s+is)?\s+([^)]+)\)\s*$/i;
|
||||
const m = re.exec(desc);
|
||||
if (!m) return { cleaned: desc };
|
||||
|
||||
return {
|
||||
cleaned: desc.slice(0, m.index).trimEnd(),
|
||||
defaultRaw: m[1].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a simple enum from a help description.
|
||||
*
|
||||
* Looks for a parenthesized `a|b|c` list, e.g.:
|
||||
* - `log level (debug|info|warn|error)`
|
||||
*
|
||||
* @param desc - Full description string from help output.
|
||||
* @returns Cleaned description + enum values (if confidently detected).
|
||||
*/
|
||||
function extractEnum(desc: string): { cleaned: string; enum?: string[] } {
|
||||
const re = /\(([^()]*\|[^()]*)\)/;
|
||||
const m = re.exec(desc);
|
||||
if (!m) return { cleaned: desc };
|
||||
|
||||
const parts = m[1]
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
// Guard against false positives: only accept "simple" enum tokens.
|
||||
if (parts.length < 2 || parts.some((p) => !/^[A-Za-z0-9._-]+$/.test(p))) {
|
||||
return { cleaned: desc };
|
||||
}
|
||||
|
||||
const cleaned = (desc.slice(0, m.index) + desc.slice(m.index + m[0].length))
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
return { cleaned, enum: parts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a contiguous flag section (either `Flags:` or `Global Flags:`) from help output.
|
||||
*
|
||||
* @param lines - Entire help output split into lines.
|
||||
* @param startIdx - Line index immediately after the section header.
|
||||
* @returns Parsed flags, plus the index where parsing stopped.
|
||||
*/
|
||||
function parseFlagsFromSection(
|
||||
lines: string[],
|
||||
startIdx: number,
|
||||
): { flags: FlagSpec[]; endIdx: number } {
|
||||
const out: FlagSpec[] = [];
|
||||
let last: FlagSpec | null = null;
|
||||
|
||||
for (let i = startIdx; i < lines.length; i++) {
|
||||
const raw = lines[i].replace(/\t/g, " ").trimEnd();
|
||||
|
||||
// Hugo ends with a standard "Use ..." hint; treat that as a hard stop.
|
||||
if (raw.startsWith('Use "hugo ')) return { flags: out, endIdx: i };
|
||||
|
||||
// If we hit another section header (e.g. `Global Flags:` after `Flags:`), stop.
|
||||
const header = raw.trim().match(SECTION_HEADER);
|
||||
if (header) return { flags: out, endIdx: i };
|
||||
|
||||
const m = raw.match(FLAG_LINE);
|
||||
if (m?.groups?.long && m.groups.desc) {
|
||||
const long = m.groups.long;
|
||||
|
||||
// Drop `--help` so it doesn't appear in generated option types.
|
||||
if (long === "--help") {
|
||||
last = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cobra's help formatting makes the type column optional. For boolean flags, the first
|
||||
// word of the description can get mis-captured as a "type". Guard with a whitelist.
|
||||
let typeToken: string | undefined = m.groups.type;
|
||||
let desc = m.groups.desc.trim();
|
||||
|
||||
if (typeToken && !KNOWN_TYPE_TOKENS.has(typeToken.toLowerCase())) {
|
||||
desc = `${typeToken} ${desc}`.trim();
|
||||
typeToken = undefined;
|
||||
}
|
||||
|
||||
// Pull defaults/enums out of the description while preserving raw values.
|
||||
const def = extractDefault(desc);
|
||||
desc = def.cleaned;
|
||||
|
||||
const en = extractEnum(desc);
|
||||
desc = en.cleaned;
|
||||
|
||||
out.push({
|
||||
long,
|
||||
short: m.groups.short,
|
||||
typeToken,
|
||||
kind: mapTypeTokenToKind(typeToken),
|
||||
description: desc,
|
||||
defaultRaw: def.defaultRaw,
|
||||
enum: en.enum,
|
||||
});
|
||||
|
||||
last = out[out.length - 1];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Merge wrapped description lines into the previous flag.
|
||||
const c = raw.match(CONTINUATION_LINE);
|
||||
if (c?.groups?.more && last) {
|
||||
last.description = `${last.description} ${c.groups.more.trim()}`
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
} else {
|
||||
last = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { flags: out, endIdx: lines.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `Available Commands:` names from a help output.
|
||||
*
|
||||
* @param helpText - Full help output.
|
||||
* @returns List of subcommand names (single token each).
|
||||
*/
|
||||
function parseAvailableCommands(helpText: string): string[] {
|
||||
const lines = helpText.split(/\r?\n/);
|
||||
const idx = lines.findIndex((l) => l.trim() === "Available Commands:");
|
||||
if (idx === -1) return [];
|
||||
|
||||
const out: string[] = [];
|
||||
for (let i = idx + 1; i < lines.length; i++) {
|
||||
const raw = lines[i].trimEnd();
|
||||
if (raw.trim() === "") continue;
|
||||
|
||||
// Stop on the next section header.
|
||||
if (/^[A-Z][A-Za-z ]+:$/.test(raw.trim())) break;
|
||||
|
||||
// Typical format: " server Start the embedded web server"
|
||||
const mm = raw.match(/^\s{2,}(?<name>[a-z0-9][a-z0-9-]*)\s{2,}.+$/i);
|
||||
if (mm?.groups?.name) out.push(mm.groups.name);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the relevant parts of a Hugo command’s help output:
|
||||
* - `Flags:`
|
||||
* - `Global Flags:`
|
||||
* - `Available Commands:`
|
||||
*
|
||||
* @param helpText - Full help output for a command.
|
||||
* @param pathTokens - Command path tokens (e.g. `["server"]`, `["mod","get"]`, `["root"]`).
|
||||
* @returns Parsed command metadata.
|
||||
*/
|
||||
function parseCommandHelp(helpText: string, pathTokens: string[]): CommandSpec {
|
||||
const lines = helpText.split(/\r?\n/);
|
||||
|
||||
let flags: FlagSpec[] = [];
|
||||
let globalFlags: FlagSpec[] = [];
|
||||
|
||||
const flagsHeaderIdx = lines.findIndex((l) => l.trim() === "Flags:");
|
||||
if (flagsHeaderIdx !== -1) {
|
||||
flags = parseFlagsFromSection(lines, flagsHeaderIdx + 1).flags;
|
||||
}
|
||||
|
||||
const globalHeaderIdx = lines.findIndex((l) => l.trim() === "Global Flags:");
|
||||
if (globalHeaderIdx !== -1) {
|
||||
globalFlags = parseFlagsFromSection(lines, globalHeaderIdx + 1).flags;
|
||||
}
|
||||
|
||||
const subcommands = parseAvailableCommands(helpText);
|
||||
return { pathTokens, flags, globalFlags, subcommands };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the leading `--` from a long flag name.
|
||||
*
|
||||
* @param long - Long flag name, e.g. `--baseURL`.
|
||||
* @returns Name without the `--` prefix, e.g. `baseURL`.
|
||||
*/
|
||||
function normalizeLong(long: string) {
|
||||
return long.startsWith("--") ? long.slice(2) : long;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert kebab-case to camelCase. If the name is already mixedCase (e.g. `baseURL`),
|
||||
* it is returned as-is.
|
||||
*
|
||||
* @param name - Flag name without the `--` prefix.
|
||||
* @returns JS/TS-friendly property name.
|
||||
*/
|
||||
function camelizeIfKebab(name: string) {
|
||||
if (!name.includes("-")) return name;
|
||||
const [first, ...rest] = name.split("-");
|
||||
return (
|
||||
first + rest.map((p) => (p ? p[0].toUpperCase() + p.slice(1) : "")).join("")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tokens to PascalCase (used to generate interface names).
|
||||
*
|
||||
* @param tokens - Command path tokens (e.g. `["mod","get"]`).
|
||||
* @returns PascalCase string (e.g. `ModGet`).
|
||||
*/
|
||||
function pascal(tokens: string[]) {
|
||||
return tokens.map((t) => (t ? t[0].toUpperCase() + t.slice(1) : "")).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a normalized {@link FlagKind} + optional enum into a TypeScript type string.
|
||||
*
|
||||
* @param kind - Normalized kind.
|
||||
* @param en - Optional enum values inferred from description.
|
||||
* @returns TypeScript type representation for emitted code.
|
||||
*/
|
||||
function kindToTs(kind: FlagKind, en?: string[]) {
|
||||
if (en?.length) return en.map((v) => JSON.stringify(v)).join(" | ");
|
||||
switch (kind) {
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
case "string":
|
||||
return "string";
|
||||
case "number":
|
||||
return "number";
|
||||
case "string[]":
|
||||
return "string[]";
|
||||
case "number[]":
|
||||
return "number[]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate flags by their long name. First occurrence wins.
|
||||
*
|
||||
* @param flags - Flags to dedupe.
|
||||
* @returns Deduped list.
|
||||
*/
|
||||
function dedupeByLong(flags: FlagSpec[]): FlagSpec[] {
|
||||
const seen = new Map<string, FlagSpec>();
|
||||
for (const f of flags) if (!seen.has(f.long)) seen.set(f.long, f);
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit TypeScript interfaces and helper types for Hugo commands:
|
||||
* - `HugoGlobalOptions`
|
||||
* - `Hugo<CommandPath>Options` for each command
|
||||
* - `HugoCommand` union and `HugoOptionsFor<>` conditional mapping
|
||||
*
|
||||
* @param globalFlags - Persistent/global flags (from `Global Flags:` sections).
|
||||
* @param commands - Command metadata to emit (command-local flags).
|
||||
* @returns TypeScript source as a single string.
|
||||
*/
|
||||
function emitInterfaces(globalFlags: FlagSpec[], commands: CommandSpec[]) {
|
||||
const lines: string[] = [];
|
||||
lines.push(`/* eslint-disable */`);
|
||||
lines.push(`// AUTO-GENERATED. DO NOT EDIT.`);
|
||||
lines.push("");
|
||||
|
||||
lines.push(`export interface HugoGlobalOptions {`);
|
||||
for (const f of globalFlags.sort((a, b) => a.long.localeCompare(b.long))) {
|
||||
const prop = camelizeIfKebab(normalizeLong(f.long));
|
||||
const tsType = kindToTs(f.kind, f.enum);
|
||||
const def = f.defaultRaw ? ` (default ${f.defaultRaw})` : "";
|
||||
lines.push(` /** ${f.description}${def} */`);
|
||||
lines.push(` ${prop}?: ${tsType};`);
|
||||
}
|
||||
lines.push(`}`);
|
||||
lines.push("");
|
||||
|
||||
for (const cmd of commands.sort((a, b) =>
|
||||
a.pathTokens.join(" ").localeCompare(b.pathTokens.join(" ")),
|
||||
)) {
|
||||
const name = `Hugo${pascal(cmd.pathTokens)}Options`;
|
||||
lines.push(`export interface ${name} extends HugoGlobalOptions {`);
|
||||
for (const f of cmd.flags.sort((a, b) => a.long.localeCompare(b.long))) {
|
||||
const prop = camelizeIfKebab(normalizeLong(f.long));
|
||||
const tsType = kindToTs(f.kind, f.enum);
|
||||
const def = f.defaultRaw ? ` (default ${f.defaultRaw})` : "";
|
||||
lines.push(` /** ${f.description}${def} */`);
|
||||
lines.push(` ${prop}?: ${tsType};`);
|
||||
}
|
||||
lines.push(`}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
const cmdStrings = commands.map((c) => c.pathTokens.join(" "));
|
||||
lines.push(
|
||||
`export type HugoCommand = ${cmdStrings.map((s) => JSON.stringify(s)).join(" | ")};`,
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(`export type HugoOptionsFor<C extends HugoCommand> =`);
|
||||
for (const cmd of commands) {
|
||||
const s = cmd.pathTokens.join(" ");
|
||||
const name = `Hugo${pascal(cmd.pathTokens)}Options`;
|
||||
lines.push(` C extends ${JSON.stringify(s)} ? ${name} :`);
|
||||
}
|
||||
lines.push(` never;`);
|
||||
lines.push("");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the Hugo binary with the provided args and return the help text.
|
||||
*
|
||||
* Hugo prints help to stdout in the cases we rely on.
|
||||
*
|
||||
* @param bin - Absolute path to the Hugo executable resolved by this package.
|
||||
* @param args - CLI args to pass (e.g. `["server","--help"]`).
|
||||
* @returns Help output (stdout).
|
||||
*/
|
||||
async function getHelp(bin: string, args: string[]) {
|
||||
const out = await x(bin, args, { throwOnError: true });
|
||||
return out.stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get help text for a command, using the appropriate method.
|
||||
* Some commands (like `new`) redirect `--help` to a default subcommand,
|
||||
* so we use `help <command>` instead to see the parent command structure.
|
||||
*
|
||||
* @param bin - Absolute path to the Hugo executable.
|
||||
* @param tokens - Command path tokens (e.g. `["new"]`).
|
||||
* @returns Help output showing subcommands if they exist.
|
||||
*/
|
||||
async function getCommandHelp(bin: string, tokens: string[]) {
|
||||
if (tokens.length === 0) {
|
||||
return getHelp(bin, ["--help"]);
|
||||
}
|
||||
|
||||
// First try using `help <command>` to see if subcommands are listed
|
||||
const helpArgs = ["help", ...tokens];
|
||||
const helpOutput = await getHelp(bin, helpArgs);
|
||||
|
||||
// If we see "Available Commands:" in the output, use this version
|
||||
if (helpOutput.includes("Available Commands:")) {
|
||||
return helpOutput;
|
||||
}
|
||||
|
||||
// Otherwise fall back to the standard `<command> --help`
|
||||
const stdArgs = [...tokens, "--help"];
|
||||
return getHelp(bin, stdArgs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point: discovers the Hugo command tree, parses flags, and emits:
|
||||
* - `src/types.ts` (types/interfaces)
|
||||
* - `src/hugo.spec.json` (runtime spec for argv building)
|
||||
*/
|
||||
async function run() {
|
||||
const bin = await hugo();
|
||||
|
||||
// BFS over command tree; `[]` means root.
|
||||
const queue: string[][] = [[]];
|
||||
const visited = new Set<string>();
|
||||
|
||||
const commandSpecs: CommandSpec[] = [];
|
||||
const globalFlagsAll: FlagSpec[] = [];
|
||||
|
||||
while (queue.length) {
|
||||
const tokens = queue.shift() ?? [];
|
||||
const key = tokens.join(" ");
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
|
||||
const helpText = await getCommandHelp(bin, tokens);
|
||||
|
||||
// Root is used only for discovery (naming convenience).
|
||||
const pathTokens = tokens.length ? tokens : ["root"];
|
||||
const spec = parseCommandHelp(helpText, pathTokens);
|
||||
|
||||
commandSpecs.push(spec);
|
||||
globalFlagsAll.push(...spec.globalFlags);
|
||||
|
||||
for (const sub of spec.subcommands) {
|
||||
queue.push(tokens.concat([sub]));
|
||||
}
|
||||
}
|
||||
|
||||
// Global options = union of flags found in `Global Flags:` sections.
|
||||
const globalFlags = dedupeByLong(globalFlagsAll);
|
||||
const globalLongSet = new Set(globalFlags.map((f) => f.long));
|
||||
|
||||
// Drop root and strip global flags from each command’s local flags to avoid duplicates.
|
||||
const cleanedCommands = commandSpecs
|
||||
.filter((c) => c.pathTokens[0] !== "root")
|
||||
.map((c) => ({
|
||||
...c,
|
||||
flags: c.flags.filter((f) => !globalLongSet.has(f.long)),
|
||||
}));
|
||||
|
||||
const outDir = path.join(process.cwd(), OUT_DIR);
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
|
||||
if (HUGO_TYPES_FILE) {
|
||||
await fs.writeFile(
|
||||
path.join(outDir, HUGO_TYPES_FILE),
|
||||
emitInterfaces(globalFlags, cleanedCommands),
|
||||
"utf8",
|
||||
);
|
||||
console.log(`Wrote ${HUGO_TYPES_FILE}`);
|
||||
}
|
||||
|
||||
if (HUGO_FLAGS_JSON_FILE) {
|
||||
await fs.writeFile(
|
||||
path.join(outDir, HUGO_FLAGS_JSON_FILE),
|
||||
JSON.stringify(
|
||||
{
|
||||
globalFlags,
|
||||
commands: cleanedCommands.map((c) => ({
|
||||
command: c.pathTokens.join(" "),
|
||||
flags: c.flags,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
console.log(`Wrote ${HUGO_FLAGS_JSON_FILE}`);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import hugo from "./hugo";
|
||||
|
||||
// Handle unexpected promise rejections
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error("Unhandled promise rejection:", reason);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
const bin = await hugo();
|
||||
|
||||
const child = spawn(bin, args, { stdio: "inherit" });
|
||||
|
||||
// Handle spawn errors (e.g., binary not found)
|
||||
child.on("error", (err) => {
|
||||
console.error("Failed to spawn Hugo binary:", err.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
// Forward Hugo's exit code so this module itself reports success/failure
|
||||
child.on("exit", (code) => {
|
||||
process.exitCode = code ?? undefined;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"Failed to initialize Hugo:",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
})();
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import type { HugoCommand, HugoOptionsFor } from "./generated/types";
|
||||
import { buildArgs } from "./lib/args";
|
||||
import install from "./lib/install";
|
||||
import { doesBinExist, getBinPath } from "./lib/utils";
|
||||
|
||||
/**
|
||||
* Gets the path to the Hugo binary, automatically installing it if it's missing.
|
||||
*
|
||||
* This is the main entry point for the hugo-extended package. It checks if Hugo
|
||||
* is already installed and available, and if not, triggers an automatic installation
|
||||
* before returning the binary path.
|
||||
*
|
||||
* This handles the case where Hugo may mysteriously disappear (see issue #81),
|
||||
* ensuring the binary is always available when this function is called.
|
||||
*
|
||||
* @returns A promise that resolves with the absolute path to the Hugo binary
|
||||
* @throws {Error} If installation fails or the platform is unsupported
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import hugo from 'hugo-extended';
|
||||
*
|
||||
* const hugoPath = await hugo();
|
||||
* console.log(hugoPath); // "/usr/local/bin/hugo" or "./bin/hugo"
|
||||
* ```
|
||||
*/
|
||||
export const getHugoBinary = async (): Promise<string> => {
|
||||
const bin = getBinPath();
|
||||
|
||||
// A fix for fleeting ENOENT errors, where Hugo seems to disappear. For now,
|
||||
// just reinstall Hugo when it's missing and then continue normally like
|
||||
// nothing happened.
|
||||
// See: https://github.com/jakejarvis/hugo-extended/issues/81
|
||||
if (!doesBinExist(bin)) {
|
||||
// Hugo isn't there for some reason. Try re-installing.
|
||||
console.info("⚠️ Hugo is missing, reinstalling now...");
|
||||
await install();
|
||||
}
|
||||
|
||||
return bin;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a Hugo command with type-safe options.
|
||||
*
|
||||
* This function runs Hugo with the specified command and options, inheriting stdio
|
||||
* so output goes directly to the console. It's perfect for interactive commands
|
||||
* like `hugo server` or build commands where you want to see live output.
|
||||
*
|
||||
* @param command - Hugo command to execute (e.g., "server", "build", "mod clean")
|
||||
* @param positionalArgsOrOptions - Either positional arguments array or options object
|
||||
* @param options - Type-safe options object (if first param is positional args)
|
||||
* @returns A promise that resolves when the command completes successfully
|
||||
* @throws {Error} If the command fails or Hugo is not available
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { exec } from 'hugo-extended';
|
||||
*
|
||||
* // Start development server
|
||||
* await exec("server", {
|
||||
* port: 1313,
|
||||
* buildDrafts: true,
|
||||
* baseURL: "http://localhost:1313"
|
||||
* });
|
||||
*
|
||||
* // Create a new site
|
||||
* await exec("new site", ["my-site"], { format: "yaml" });
|
||||
*
|
||||
* // Build site for production
|
||||
* await exec("build", {
|
||||
* minify: true,
|
||||
* cleanDestinationDir: true
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function exec<C extends HugoCommand>(
|
||||
command: C,
|
||||
positionalArgsOrOptions?: string[] | HugoOptionsFor<C>,
|
||||
options?: HugoOptionsFor<C>,
|
||||
): Promise<void> {
|
||||
const bin = await getHugoBinary();
|
||||
|
||||
// Handle overloaded parameters
|
||||
let positionalArgs: string[] | undefined;
|
||||
let opts: HugoOptionsFor<C> | undefined;
|
||||
|
||||
if (Array.isArray(positionalArgsOrOptions)) {
|
||||
positionalArgs = positionalArgsOrOptions;
|
||||
opts = options;
|
||||
} else {
|
||||
positionalArgs = undefined;
|
||||
opts = positionalArgsOrOptions;
|
||||
}
|
||||
|
||||
const args = buildArgs(
|
||||
command,
|
||||
positionalArgs,
|
||||
opts as Record<string, unknown>,
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, args, { stdio: "inherit" });
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0 || code === null) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Hugo command failed with exit code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Hugo command and capture its output.
|
||||
*
|
||||
* This function runs Hugo with the specified command and options, capturing
|
||||
* stdout and stderr. It's useful for commands where you need to process the
|
||||
* output programmatically, like `hugo version` or `hugo list all`.
|
||||
*
|
||||
* @param command - Hugo command to execute (e.g., "version", "list all")
|
||||
* @param positionalArgsOrOptions - Either positional arguments array or options object
|
||||
* @param options - Type-safe options object (if first param is positional args)
|
||||
* @returns A promise that resolves with stdout and stderr strings
|
||||
* @throws {Error} If the command fails or Hugo is not available
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { execWithOutput } from 'hugo-extended';
|
||||
*
|
||||
* // Get Hugo version
|
||||
* const { stdout } = await execWithOutput("version");
|
||||
* console.log(stdout); // "hugo v0.154.3+extended ..."
|
||||
*
|
||||
* // List all content
|
||||
* const { stdout: content } = await execWithOutput("list all");
|
||||
* const pages = content.split('\n');
|
||||
* ```
|
||||
*/
|
||||
export async function execWithOutput<C extends HugoCommand>(
|
||||
command: C,
|
||||
positionalArgsOrOptions?: string[] | HugoOptionsFor<C>,
|
||||
options?: HugoOptionsFor<C>,
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const bin = await getHugoBinary();
|
||||
|
||||
// Handle overloaded parameters
|
||||
let positionalArgs: string[] | undefined;
|
||||
let opts: HugoOptionsFor<C> | undefined;
|
||||
|
||||
if (Array.isArray(positionalArgsOrOptions)) {
|
||||
positionalArgs = positionalArgsOrOptions;
|
||||
opts = options;
|
||||
} else {
|
||||
positionalArgs = undefined;
|
||||
opts = positionalArgsOrOptions;
|
||||
}
|
||||
|
||||
const args = buildArgs(
|
||||
command,
|
||||
positionalArgs,
|
||||
opts as Record<string, unknown>,
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
|
||||
const child = spawn(bin, args);
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrChunks.push(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
child.on("exit", (code) => {
|
||||
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
||||
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
||||
|
||||
if (code === 0 || code === null) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`Hugo command failed with exit code ${code}${stderr ? `\n${stderr}` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder-style API for executing Hugo commands.
|
||||
*
|
||||
* Provides a fluent interface where each Hugo command is a method on the
|
||||
* builder object. All methods are type-safe with autocomplete for options.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { hugo } from 'hugo-extended';
|
||||
*
|
||||
* // Start server
|
||||
* await hugo.server({ port: 1313, buildDrafts: true });
|
||||
*
|
||||
* // Build site
|
||||
* await hugo.build({ minify: true });
|
||||
*
|
||||
* // Module operations
|
||||
* await hugo.mod.clean({ all: true });
|
||||
* await hugo.mod.get();
|
||||
* ```
|
||||
*/
|
||||
export const hugo = {
|
||||
/** Build your site */
|
||||
build: (options?: HugoOptionsFor<"build">) => exec("build", options),
|
||||
|
||||
/** Generate shell completion scripts */
|
||||
completion: {
|
||||
bash: (options?: HugoOptionsFor<"completion bash">) =>
|
||||
exec("completion bash", options),
|
||||
fish: (options?: HugoOptionsFor<"completion fish">) =>
|
||||
exec("completion fish", options),
|
||||
powershell: (options?: HugoOptionsFor<"completion powershell">) =>
|
||||
exec("completion powershell", options),
|
||||
zsh: (options?: HugoOptionsFor<"completion zsh">) =>
|
||||
exec("completion zsh", options),
|
||||
},
|
||||
|
||||
/** Print Hugo configuration */
|
||||
config: (options?: HugoOptionsFor<"config">) => exec("config", options),
|
||||
|
||||
/** Convert content to different formats */
|
||||
convert: {
|
||||
toJSON: (options?: HugoOptionsFor<"convert toJSON">) =>
|
||||
exec("convert toJSON", options),
|
||||
toTOML: (options?: HugoOptionsFor<"convert toTOML">) =>
|
||||
exec("convert toTOML", options),
|
||||
toYAML: (options?: HugoOptionsFor<"convert toYAML">) =>
|
||||
exec("convert toYAML", options),
|
||||
},
|
||||
|
||||
/** Print Hugo environment info */
|
||||
env: (options?: HugoOptionsFor<"env">) => exec("env", options),
|
||||
|
||||
/** Generate documentation */
|
||||
gen: {
|
||||
doc: (options?: HugoOptionsFor<"gen doc">) => exec("gen doc", options),
|
||||
man: (options?: HugoOptionsFor<"gen man">) => exec("gen man", options),
|
||||
},
|
||||
|
||||
/** Import your site from others */
|
||||
import: {
|
||||
jekyll: (options?: HugoOptionsFor<"import jekyll">) =>
|
||||
exec("import jekyll", options),
|
||||
},
|
||||
|
||||
/** List various types of content */
|
||||
list: {
|
||||
all: (options?: HugoOptionsFor<"list all">) => exec("list all", options),
|
||||
drafts: (options?: HugoOptionsFor<"list drafts">) =>
|
||||
exec("list drafts", options),
|
||||
expired: (options?: HugoOptionsFor<"list expired">) =>
|
||||
exec("list expired", options),
|
||||
future: (options?: HugoOptionsFor<"list future">) =>
|
||||
exec("list future", options),
|
||||
published: (options?: HugoOptionsFor<"list published">) =>
|
||||
exec("list published", options),
|
||||
},
|
||||
|
||||
/** Module operations */
|
||||
mod: {
|
||||
clean: (options?: HugoOptionsFor<"mod clean">) =>
|
||||
exec("mod clean", options),
|
||||
get: (options?: HugoOptionsFor<"mod get">) => exec("mod get", options),
|
||||
graph: (options?: HugoOptionsFor<"mod graph">) =>
|
||||
exec("mod graph", options),
|
||||
init: (options?: HugoOptionsFor<"mod init">) => exec("mod init", options),
|
||||
npm: {
|
||||
pack: (options?: HugoOptionsFor<"mod npm pack">) =>
|
||||
exec("mod npm pack", options),
|
||||
},
|
||||
tidy: (options?: HugoOptionsFor<"mod tidy">) => exec("mod tidy", options),
|
||||
vendor: (options?: HugoOptionsFor<"mod vendor">) =>
|
||||
exec("mod vendor", options),
|
||||
verify: (options?: HugoOptionsFor<"mod verify">) =>
|
||||
exec("mod verify", options),
|
||||
},
|
||||
|
||||
/** Create new content */
|
||||
new: Object.assign(
|
||||
(
|
||||
pathOrOptions?: string | HugoOptionsFor<"new">,
|
||||
options?: HugoOptionsFor<"new">,
|
||||
) => {
|
||||
if (typeof pathOrOptions === "string") {
|
||||
return exec("new", [pathOrOptions], options);
|
||||
}
|
||||
return exec("new", pathOrOptions);
|
||||
},
|
||||
{
|
||||
content: (
|
||||
pathOrOptions?: string | HugoOptionsFor<"new content">,
|
||||
options?: HugoOptionsFor<"new content">,
|
||||
) => {
|
||||
if (typeof pathOrOptions === "string") {
|
||||
return exec("new content", [pathOrOptions], options);
|
||||
}
|
||||
return exec("new content", pathOrOptions);
|
||||
},
|
||||
site: (
|
||||
pathOrOptions?: string | HugoOptionsFor<"new site">,
|
||||
options?: HugoOptionsFor<"new site">,
|
||||
) => {
|
||||
if (typeof pathOrOptions === "string") {
|
||||
return exec("new site", [pathOrOptions], options);
|
||||
}
|
||||
return exec("new site", pathOrOptions);
|
||||
},
|
||||
theme: (
|
||||
nameOrOptions?: string | HugoOptionsFor<"new theme">,
|
||||
options?: HugoOptionsFor<"new theme">,
|
||||
) => {
|
||||
if (typeof nameOrOptions === "string") {
|
||||
return exec("new theme", [nameOrOptions], options);
|
||||
}
|
||||
return exec("new theme", nameOrOptions);
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
/** Start the Hugo development server */
|
||||
server: (options?: HugoOptionsFor<"server">) => exec("server", options),
|
||||
|
||||
/** Print the Hugo version */
|
||||
version: (options?: HugoOptionsFor<"version">) => exec("version", options),
|
||||
};
|
||||
|
||||
// Backward compatibility: default export still returns the binary path
|
||||
const hugoCompat = getHugoBinary;
|
||||
|
||||
// Make the default export callable AND have builder properties
|
||||
export default Object.assign(hugoCompat, hugo);
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { HugoCommand, HugoOptionsFor } from "./generated/types";
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Flag specification loaded from the generated spec.json file.
|
||||
*/
|
||||
type FlagSpec = {
|
||||
long: string;
|
||||
short?: string;
|
||||
typeToken?: string;
|
||||
kind: "boolean" | "string" | "number" | "string[]" | "number[]";
|
||||
description: string;
|
||||
enum?: string[];
|
||||
defaultRaw?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Command specification with its local flags.
|
||||
*/
|
||||
type CommandSpec = {
|
||||
command: string;
|
||||
flags: FlagSpec[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The complete spec loaded from spec.json.
|
||||
*/
|
||||
type HugoSpec = {
|
||||
globalFlags: FlagSpec[];
|
||||
commands: CommandSpec[];
|
||||
};
|
||||
|
||||
let cachedSpec: HugoSpec | null = null;
|
||||
|
||||
/**
|
||||
* Load the Hugo spec from the generated json file (cached after first load).
|
||||
*
|
||||
* @returns The parsed Hugo spec containing global flags and command-specific flags.
|
||||
*/
|
||||
function loadSpec(): HugoSpec {
|
||||
if (cachedSpec) return cachedSpec;
|
||||
|
||||
const specPath = path.join(__dirname, "..", "generated", "flags.json");
|
||||
try {
|
||||
const specText = fs.readFileSync(specPath, "utf8");
|
||||
cachedSpec = JSON.parse(specText) as HugoSpec;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to load Hugo spec from ${specPath}. ` +
|
||||
`Ensure the project is built (npm run build) before use.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return cachedSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a camelCase property name to kebab-case flag name.
|
||||
*
|
||||
* @param name - Property name in camelCase (e.g., "buildDrafts").
|
||||
* @returns Kebab-case flag name (e.g., "build-drafts").
|
||||
*
|
||||
* @example
|
||||
* camelToKebab("baseURL") // "base-u-r-l"
|
||||
* camelToKebab("buildDrafts") // "build-drafts"
|
||||
*/
|
||||
function camelToKebab(name: string): string {
|
||||
return name.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a flag spec by its camelCase property name.
|
||||
*
|
||||
* @param flags - Array of flag specs to search.
|
||||
* @param propName - Property name in camelCase.
|
||||
* @returns The matching flag spec, or undefined if not found.
|
||||
*/
|
||||
function findFlag(flags: FlagSpec[], propName: string): FlagSpec | undefined {
|
||||
const kebab = camelToKebab(propName);
|
||||
return flags.find((f) => {
|
||||
const flagName = f.long.startsWith("--") ? f.long.slice(2) : f.long;
|
||||
return flagName === kebab || flagName === propName;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build command-line arguments from a command and options object.
|
||||
*
|
||||
* This function:
|
||||
* 1. Loads the Hugo spec to understand flag types
|
||||
* 2. Converts camelCase property names to kebab-case flags
|
||||
* 3. Formats values according to their types (boolean, string, number, arrays)
|
||||
* 4. Returns an argv array ready to pass to child_process
|
||||
*
|
||||
* @param command - Hugo command string (e.g., "server", "build", "mod clean").
|
||||
* @param positionalArgs - Optional array of positional arguments (e.g., paths, names).
|
||||
* @param options - Options object with camelCase property names.
|
||||
* @returns Array of command-line arguments.
|
||||
*
|
||||
* @example
|
||||
* buildArgs("server", undefined, { port: 1313, buildDrafts: true })
|
||||
* // Returns: ["server", "--port", "1313", "--build-drafts"]
|
||||
*
|
||||
* @example
|
||||
* buildArgs("new site", ["my-site"], { format: "yaml" })
|
||||
* // Returns: ["new", "site", "my-site", "--format", "yaml"]
|
||||
*
|
||||
* @example
|
||||
* buildArgs("build", undefined, { theme: ["a", "b"], minify: true })
|
||||
* // Returns: ["build", "--theme", "a", "--theme", "b", "--minify"]
|
||||
*/
|
||||
export function buildArgs(
|
||||
command: string,
|
||||
positionalArgs?: string[],
|
||||
options?: Record<string, unknown>,
|
||||
): string[] {
|
||||
const spec = loadSpec();
|
||||
const args: string[] = [];
|
||||
|
||||
// Add the command tokens (e.g., "mod clean" becomes ["mod", "clean"])
|
||||
args.push(...command.split(" "));
|
||||
|
||||
// Add positional arguments after the command
|
||||
if (positionalArgs && positionalArgs.length > 0) {
|
||||
args.push(...positionalArgs);
|
||||
}
|
||||
|
||||
// If no options, return command + positional args
|
||||
if (!options || Object.keys(options).length === 0) {
|
||||
return args;
|
||||
}
|
||||
|
||||
// Find the command spec
|
||||
const cmdSpec = spec.commands.find((c) => c.command === command);
|
||||
|
||||
// Combine global flags and command-specific flags
|
||||
const allFlags = [...spec.globalFlags, ...(cmdSpec?.flags ?? [])];
|
||||
|
||||
// Process each option
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
// Skip undefined/null values
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
// Find the flag spec for this property
|
||||
const flagSpec = findFlag(allFlags, key);
|
||||
|
||||
// If we don't have a spec, try to infer the format
|
||||
const flagName = flagSpec
|
||||
? flagSpec.long.startsWith("--")
|
||||
? flagSpec.long
|
||||
: `--${flagSpec.long}`
|
||||
: `--${camelToKebab(key)}`;
|
||||
|
||||
const kind = flagSpec?.kind ?? inferKind(value);
|
||||
|
||||
// Format based on kind
|
||||
switch (kind) {
|
||||
case "boolean":
|
||||
// Only add the flag if true
|
||||
if (value === true) {
|
||||
args.push(flagName);
|
||||
}
|
||||
break;
|
||||
|
||||
case "string":
|
||||
args.push(flagName, String(value));
|
||||
break;
|
||||
|
||||
case "number":
|
||||
args.push(flagName, String(value));
|
||||
break;
|
||||
|
||||
case "string[]":
|
||||
// Repeat the flag for each array element
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
args.push(flagName, String(item));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "number[]":
|
||||
// Repeat the flag for each array element
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
args.push(flagName, String(item));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the kind of a value when we don't have spec information.
|
||||
*
|
||||
* @param value - The value to inspect.
|
||||
* @returns The inferred flag kind.
|
||||
*/
|
||||
function inferKind(
|
||||
value: unknown,
|
||||
): "boolean" | "string" | "number" | "string[]" | "number[]" {
|
||||
if (typeof value === "boolean") return "boolean";
|
||||
if (typeof value === "number") return "number";
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0 && typeof value[0] === "number") return "number[]";
|
||||
return "string[]";
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import AdmZip from "adm-zip";
|
||||
import * as tar from "tar";
|
||||
import {
|
||||
getBinFilename,
|
||||
getBinVersion,
|
||||
getChecksumFilename,
|
||||
getPkgVersion,
|
||||
getReleaseFilename,
|
||||
getReleaseUrl,
|
||||
isExtended,
|
||||
} from "./utils";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Downloads a file from a URL to a local destination path.
|
||||
*
|
||||
* @param url - The URL to download the file from
|
||||
* @param dest - The local file path where the downloaded file will be saved
|
||||
* @throws {Error} If the download fails or the response is invalid
|
||||
* @returns A promise that resolves when the download is complete
|
||||
*/
|
||||
async function downloadFile(url: string, dest: string): Promise<void> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${url}: ${response.statusText}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error(`No response body from ${url}`);
|
||||
}
|
||||
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(dest));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a downloaded file matches its expected SHA-256 checksum.
|
||||
*
|
||||
* Downloads the checksums file from GitHub, extracts the expected checksum for the
|
||||
* specified filename, computes the actual checksum of the local file, and compares them.
|
||||
*
|
||||
* @param filePath - The local path to the file to verify
|
||||
* @param checksumUrl - The URL to the checksums file (usually checksums.txt from the release)
|
||||
* @param filename - The name of the file to find in the checksums file
|
||||
* @throws {Error} If checksums don't match, the checksums file can't be downloaded, or the filename isn't found
|
||||
* @returns A promise that resolves when verification is successful
|
||||
*/
|
||||
async function verifyChecksum(
|
||||
filePath: string,
|
||||
checksumUrl: string,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch(checksumUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download checksums: ${response.statusText}`);
|
||||
}
|
||||
const checksums = await response.text();
|
||||
|
||||
// checksums file format: "sha256 filename"
|
||||
const expectedChecksum = checksums
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split(/\s+/))
|
||||
.find((tokens) => tokens[tokens.length - 1] === filename)?.[0];
|
||||
|
||||
if (!expectedChecksum) {
|
||||
throw new Error(`Checksum for ${filename} not found in checksums file.`);
|
||||
}
|
||||
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(fileBuffer);
|
||||
const actualChecksum = hash.digest("hex");
|
||||
|
||||
if (actualChecksum !== expectedChecksum) {
|
||||
throw new Error(
|
||||
`Checksum mismatch! Expected ${expectedChecksum}, got ${actualChecksum}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads, verifies, and installs Hugo (Extended when available) for the current platform.
|
||||
*
|
||||
* This function handles the complete installation process:
|
||||
* - Determines the correct Hugo release file for the current platform and architecture
|
||||
* - Downloads the release file and checksums from GitHub
|
||||
* - Verifies the integrity of the downloaded file using SHA-256 checksums
|
||||
* - Extracts or installs the binary (platform-specific):
|
||||
* - macOS: Uses `sudo installer` to install the .pkg file to /usr/local/bin
|
||||
* - Windows/Linux: Extracts the .zip or .tar.gz archive to the local bin directory
|
||||
* - Sets appropriate file permissions on Unix-like systems
|
||||
* - Displays the installed Hugo version
|
||||
*
|
||||
* @throws {Error} If the platform is unsupported, download fails, checksum doesn't match, or installation fails
|
||||
* @returns A promise that resolves with the absolute path to the installed Hugo binary
|
||||
*/
|
||||
async function install(): Promise<string> {
|
||||
try {
|
||||
const version = getPkgVersion();
|
||||
const releaseFile = getReleaseFilename(version);
|
||||
const checksumFile = getChecksumFilename(version);
|
||||
const binFile = getBinFilename();
|
||||
|
||||
if (!releaseFile) {
|
||||
throw new Error(
|
||||
`Are you sure this platform is supported? See: https://github.com/gohugoio/hugo/releases/tag/v${version}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isExtended(releaseFile)) {
|
||||
console.warn(
|
||||
"ℹ️ Hugo Extended isn't supported on this platform, downloading vanilla Hugo instead.",
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare bin directory
|
||||
const binDir = path.join(__dirname, "..", "..", "bin");
|
||||
if (!fs.existsSync(binDir)) {
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
}
|
||||
|
||||
const releaseUrl = getReleaseUrl(version, releaseFile);
|
||||
const checksumUrl = getReleaseUrl(version, checksumFile);
|
||||
const downloadPath = path.join(binDir, releaseFile);
|
||||
|
||||
console.info(`☁️ Downloading ${releaseFile}...`);
|
||||
await downloadFile(releaseUrl, downloadPath);
|
||||
|
||||
console.info("🕵️ Verifying checksum...");
|
||||
await verifyChecksum(downloadPath, checksumUrl, releaseFile);
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
console.info(`💾 Installing ${releaseFile} (requires sudo)...`);
|
||||
// Run MacOS installer
|
||||
const result = spawnSync(
|
||||
"sudo",
|
||||
["installer", "-pkg", downloadPath, "-target", "/"],
|
||||
{
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Installer failed with exit code ${result.status}`);
|
||||
}
|
||||
|
||||
// Cleanup downloaded pkg
|
||||
fs.unlinkSync(downloadPath);
|
||||
|
||||
// Create symlink in local bin dir for consistency with other platforms
|
||||
const symlinkPath = path.join(binDir, binFile);
|
||||
if (
|
||||
fs.existsSync(symlinkPath) ||
|
||||
fs.lstatSync(symlinkPath, { throwIfNoEntry: false })
|
||||
) {
|
||||
fs.unlinkSync(symlinkPath);
|
||||
}
|
||||
fs.symlinkSync("/usr/local/bin/hugo", symlinkPath);
|
||||
} else {
|
||||
console.info("📦 Extracting...");
|
||||
|
||||
if (releaseFile.endsWith(".zip")) {
|
||||
const zip = new AdmZip(downloadPath);
|
||||
zip.extractAllTo(binDir, true);
|
||||
|
||||
// Cleanup zip
|
||||
fs.unlinkSync(downloadPath);
|
||||
} else if (releaseFile.endsWith(".tar.gz")) {
|
||||
await tar.x({
|
||||
file: downloadPath,
|
||||
cwd: binDir,
|
||||
});
|
||||
|
||||
// Cleanup tar.gz
|
||||
fs.unlinkSync(downloadPath);
|
||||
}
|
||||
|
||||
const binPath = path.join(binDir, binFile);
|
||||
if (fs.existsSync(binPath)) {
|
||||
fs.chmodSync(binPath, 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
console.info("🎉 Hugo installed successfully!");
|
||||
|
||||
// Check version and return path
|
||||
const binPath = path.join(binDir, binFile);
|
||||
console.info(getBinVersion(binPath));
|
||||
return binPath;
|
||||
} catch (error) {
|
||||
console.error("⛔ Hugo installation failed. :(");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default install;
|
||||
@@ -0,0 +1,168 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Gets the Hugo version to install from package.json.
|
||||
*
|
||||
* This package's version number (should) always match the Hugo release we want.
|
||||
* We check for a `hugoVersion` field in package.json just in case it doesn't
|
||||
* match in the future (from pushing an emergency package update, etc.).
|
||||
*
|
||||
* @throws {Error} If package.json cannot be found
|
||||
* @returns The version string (e.g., "0.88.1")
|
||||
*/
|
||||
export function getPkgVersion(): string {
|
||||
// Walk up from __dirname (dist/lib) to find package.json
|
||||
const packageJsonPath = path.join(__dirname, "..", "..", "package.json");
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
return (
|
||||
(packageJson as { hugoVersion?: string; version: string }).hugoVersion ||
|
||||
packageJson.version
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Could not find or read package.json at ${packageJsonPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the full GitHub URL to a Hugo release file.
|
||||
*
|
||||
* @param version - The Hugo version number (e.g., "0.88.1")
|
||||
* @param filename - The release filename (e.g., "hugo_extended_0.88.1_darwin-universal.pkg")
|
||||
* @returns The complete download URL for the release file
|
||||
*/
|
||||
export function getReleaseUrl(version: string, filename: string): string {
|
||||
return `https://github.com/gohugoio/hugo/releases/download/v${version}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Hugo binary filename for the current platform.
|
||||
*
|
||||
* @returns "hugo.exe" on Windows, "hugo" on all other platforms
|
||||
*/
|
||||
export function getBinFilename(): string {
|
||||
return process.platform === "win32" ? "hugo.exe" : "hugo";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the absolute path to the installed Hugo binary.
|
||||
*
|
||||
* @returns The absolute path to hugo binary in the local bin directory.
|
||||
* On macOS, this is a symlink to "/usr/local/bin/hugo".
|
||||
*/
|
||||
export function getBinPath(): string {
|
||||
return path.join(__dirname, "..", "..", "bin", getBinFilename());
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the Hugo binary and returns its version string.
|
||||
*
|
||||
* @param bin - The absolute path to the Hugo binary
|
||||
* @returns The version output string (e.g., "hugo v0.88.1-5BC54738+extended darwin/arm64 BuildDate=...")
|
||||
* @throws {Error} If the binary cannot be executed
|
||||
*/
|
||||
export function getBinVersion(bin: string): string {
|
||||
const stdout = execFileSync(bin, ["version"]);
|
||||
return stdout.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Hugo binary exists at the specified path.
|
||||
*
|
||||
* @param bin - The absolute path to check for the Hugo binary
|
||||
* @returns `true` if the file exists, `false` if it doesn't
|
||||
* @throws {Error} If an unexpected error occurs (other than ENOENT)
|
||||
*/
|
||||
export function doesBinExist(bin: string): boolean {
|
||||
try {
|
||||
if (fs.existsSync(bin)) {
|
||||
return true;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// something bad happened besides Hugo not existing
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error.code !== "ENOENT"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the correct Hugo release filename for the current platform and architecture.
|
||||
*
|
||||
* Hugo Extended is available for:
|
||||
* - macOS: x64 and ARM64 (universal binaries as of v0.102.0)
|
||||
* - Linux: x64 and ARM64
|
||||
* - Windows: x64 only
|
||||
*
|
||||
* Other platform/architecture combinations fall back to vanilla Hugo where available.
|
||||
*
|
||||
* @param version - The Hugo version number (e.g., "0.88.1")
|
||||
* @returns The release filename if supported (e.g., "hugo_extended_0.88.1_darwin-universal.pkg"),
|
||||
* or `null` if the platform/architecture combination is not supported
|
||||
*/
|
||||
export function getReleaseFilename(version: string): string | null {
|
||||
const { platform, arch } = process;
|
||||
|
||||
const filename =
|
||||
// macOS: as of 0.102.0, binaries are universal
|
||||
platform === "darwin" && arch === "x64"
|
||||
? `hugo_extended_${version}_darwin-universal.pkg`
|
||||
: platform === "darwin" && arch === "arm64"
|
||||
? `hugo_extended_${version}_darwin-universal.pkg`
|
||||
: // Windows
|
||||
platform === "win32" && arch === "x64"
|
||||
? `hugo_extended_${version}_windows-amd64.zip`
|
||||
: platform === "win32" && arch === "arm64"
|
||||
? `hugo_${version}_windows-arm64.zip`
|
||||
: // Linux
|
||||
platform === "linux" && arch === "x64"
|
||||
? `hugo_extended_${version}_linux-amd64.tar.gz`
|
||||
: platform === "linux" && arch === "arm64"
|
||||
? `hugo_extended_${version}_linux-arm64.tar.gz`
|
||||
: // FreeBSD
|
||||
platform === "freebsd" && arch === "x64"
|
||||
? `hugo_${version}_freebsd-amd64.tar.gz`
|
||||
: // OpenBSD
|
||||
platform === "openbsd" && arch === "x64"
|
||||
? `hugo_${version}_openbsd-amd64.tar.gz`
|
||||
: // not gonna work :(
|
||||
null;
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the checksums filename for a given Hugo version.
|
||||
*
|
||||
* @param version - The Hugo version number (e.g., "0.88.1")
|
||||
* @returns The checksums filename (e.g., "hugo_0.88.1_checksums.txt")
|
||||
*/
|
||||
export function getChecksumFilename(version: string): string {
|
||||
return `hugo_${version}_checksums.txt`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a release filename corresponds to Hugo Extended or vanilla Hugo.
|
||||
*
|
||||
* @param releaseFile - The release filename to check (e.g., "hugo_extended_0.88.1_darwin-universal.pkg")
|
||||
* @returns `true` if the release is Hugo Extended, `false` if it's vanilla Hugo
|
||||
*/
|
||||
export function isExtended(releaseFile: string): boolean {
|
||||
return releaseFile.startsWith("hugo_extended_");
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/* global it, assert -- Globals defined by Mocha */
|
||||
import path from "path";
|
||||
import { execFile } from "child_process";
|
||||
import assert from "assert";
|
||||
import { deleteAsync } from "del";
|
||||
import hugo from "../index.js";
|
||||
import { getBinPath } from "../lib/utils.js";
|
||||
|
||||
it("Hugo exists and runs?", async function () {
|
||||
this.timeout(30000); // increase timeout to an excessive 30 seconds for CI
|
||||
|
||||
const hugoPath = await hugo();
|
||||
|
||||
// Wrap execFile in a Promise to ensure it completes before the test finishes
|
||||
await new Promise((resolve, reject) => {
|
||||
execFile(hugoPath, ["env"], function (error, stdout) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
console.log(stdout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Hugo doesn't exist, install it instead of throwing an error", async function () {
|
||||
this.timeout(30000); // increase timeout to an excessive 30 seconds for CI
|
||||
|
||||
// On macOS, the binary is installed to /usr/local/bin, which we can't/shouldn't delete.
|
||||
// We also can't easily test the reinstall flow without sudo.
|
||||
if (process.platform === "darwin") {
|
||||
console.log("Skipping reinstall test on macOS (requires sudo/system modification)");
|
||||
this.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// delete binary to ensure it's auto-reinstalled
|
||||
// Note: On Windows, we delete the specific file because deleting the directory often causes EPERM/locking issues
|
||||
if (process.platform === "win32") {
|
||||
// Retry logic for Windows EPERM issues
|
||||
const maxRetries = 5;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
await deleteAsync(getBinPath(), { force: true });
|
||||
break;
|
||||
} catch (err) {
|
||||
if (i === maxRetries - 1) throw err;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await deleteAsync(path.dirname(getBinPath()), { force: true });
|
||||
}
|
||||
|
||||
const hugoPath = await hugo();
|
||||
|
||||
assert(execFile(hugoPath, ["version"], function (error, stdout) {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(stdout);
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import hugo, { execWithOutput } from "../../src/hugo";
|
||||
|
||||
describe("Hugo Commands Integration", () => {
|
||||
beforeAll(async () => {
|
||||
// Ensure Hugo is installed
|
||||
const bin = await hugo();
|
||||
expect(bin).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("version command", () => {
|
||||
it("should return Hugo version", async () => {
|
||||
const { stdout } = await execWithOutput("version");
|
||||
expect(stdout).toContain("hugo v");
|
||||
});
|
||||
});
|
||||
|
||||
describe("env command", () => {
|
||||
it("should return environment info", async () => {
|
||||
const { stdout } = await execWithOutput("env");
|
||||
expect(stdout).toContain("GOOS");
|
||||
expect(stdout).toContain("GOARCH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("config command", () => {
|
||||
it("should return default config when no config file exists", async () => {
|
||||
// As of Hugo v0.154.x, the config command returns default configuration
|
||||
// even when no config file exists, rather than throwing an error
|
||||
const { stdout } = await execWithOutput("config");
|
||||
expect(stdout).toContain("contentdir");
|
||||
expect(stdout).toContain("publishdir");
|
||||
expect(stdout).toContain("defaultcontentlanguage");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { access, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { hugo } from "../../src/hugo";
|
||||
|
||||
describe("New Commands Integration", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temp directory for each test
|
||||
tempDir = await mkdtemp(join(tmpdir(), "hugo-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("new.site", () => {
|
||||
it("should create a new site with default format", async () => {
|
||||
const sitePath = join(tempDir, "test-site");
|
||||
await hugo.new.site(sitePath);
|
||||
|
||||
// Check that essential directories exist
|
||||
await expect(
|
||||
access(join(sitePath, "hugo.toml")),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(access(join(sitePath, "content"))).resolves.toBeUndefined();
|
||||
await expect(access(join(sitePath, "themes"))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create a new site with yaml format", async () => {
|
||||
const sitePath = join(tempDir, "test-site-yaml");
|
||||
await hugo.new.site(sitePath, { format: "yaml" });
|
||||
|
||||
await expect(
|
||||
access(join(sitePath, "hugo.yaml")),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create a new site with json format", async () => {
|
||||
const sitePath = join(tempDir, "test-site-json");
|
||||
await hugo.new.site(sitePath, { format: "json" });
|
||||
|
||||
await expect(
|
||||
access(join(sitePath, "hugo.json")),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should respect force flag", async () => {
|
||||
const sitePath = join(tempDir, "test-site-force");
|
||||
|
||||
// Create site first time
|
||||
await hugo.new.site(sitePath);
|
||||
|
||||
// Hugo 0.154.x does not overwrite an existing site config file, even with --force.
|
||||
// Future versions may change this behavior, so test accepts either outcome.
|
||||
try {
|
||||
await hugo.new.site(sitePath, { force: true });
|
||||
// If it succeeds, verify the site still exists
|
||||
await expect(
|
||||
access(join(sitePath, "hugo.toml")),
|
||||
).resolves.toBeUndefined();
|
||||
} catch (error) {
|
||||
// If it fails, that's the current 0.154.x behavior
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("new.theme", () => {
|
||||
it("should create a new theme", async () => {
|
||||
const sitePath = join(tempDir, "test-site");
|
||||
await hugo.new.site(sitePath);
|
||||
|
||||
// Use --source instead of process.chdir (not supported in worker threads)
|
||||
await hugo.new.theme("test-theme", { source: sitePath });
|
||||
|
||||
const themePath = join(sitePath, "themes", "test-theme");
|
||||
await expect(access(themePath)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
access(join(themePath, "hugo.toml")),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create theme with yaml format", async () => {
|
||||
const sitePath = join(tempDir, "test-site");
|
||||
await hugo.new.site(sitePath);
|
||||
|
||||
await hugo.new.theme("test-theme-yaml", {
|
||||
format: "yaml",
|
||||
source: sitePath,
|
||||
});
|
||||
|
||||
const themePath = join(sitePath, "themes", "test-theme-yaml");
|
||||
await expect(
|
||||
access(join(themePath, "hugo.yaml")),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("new.content", () => {
|
||||
it("should create new content file", async () => {
|
||||
const sitePath = join(tempDir, "test-site");
|
||||
await hugo.new.site(sitePath);
|
||||
|
||||
await hugo.new.content("posts/my-post.md", { source: sitePath });
|
||||
|
||||
const postPath = join(sitePath, "content", "posts", "my-post.md");
|
||||
await expect(access(postPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create content with custom kind", async () => {
|
||||
const sitePath = join(tempDir, "test-site");
|
||||
await hugo.new.site(sitePath);
|
||||
|
||||
await hugo.new.content("pages/about.md", {
|
||||
kind: "page",
|
||||
source: sitePath,
|
||||
});
|
||||
|
||||
const pagePath = join(sitePath, "content", "pages", "about.md");
|
||||
await expect(access(pagePath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("backwards compatibility", () => {
|
||||
it("should work without positional arguments", async () => {
|
||||
// Commands that don't require positional args should still work
|
||||
await expect(hugo.version()).resolves.not.toThrow();
|
||||
await expect(hugo.env()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildArgs } from "../../src/lib/args";
|
||||
|
||||
describe("buildArgs", () => {
|
||||
describe("basic command", () => {
|
||||
it("should handle command without options", () => {
|
||||
const args = buildArgs("build");
|
||||
expect(args).toEqual(["build"]);
|
||||
});
|
||||
|
||||
it("should handle multi-word command", () => {
|
||||
const args = buildArgs("mod clean");
|
||||
expect(args).toEqual(["mod", "clean"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("positional arguments", () => {
|
||||
it("should add positional arguments after command", () => {
|
||||
const args = buildArgs("new site", ["my-site"]);
|
||||
expect(args).toEqual(["new", "site", "my-site"]);
|
||||
});
|
||||
|
||||
it("should handle multiple positional arguments", () => {
|
||||
const args = buildArgs("command", ["arg1", "arg2", "arg3"]);
|
||||
expect(args).toEqual(["command", "arg1", "arg2", "arg3"]);
|
||||
});
|
||||
|
||||
it("should handle positional args with options", () => {
|
||||
const args = buildArgs("new site", ["my-site"], { format: "yaml" });
|
||||
expect(args).toEqual(["new", "site", "my-site", "--format", "yaml"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("boolean flags", () => {
|
||||
it("should add flag when true", () => {
|
||||
const args = buildArgs("build", undefined, { minify: true });
|
||||
expect(args).toContain("--minify");
|
||||
});
|
||||
|
||||
it("should not add flag when false", () => {
|
||||
const args = buildArgs("build", undefined, { minify: false });
|
||||
expect(args).not.toContain("--minify");
|
||||
});
|
||||
|
||||
it("should handle multiple boolean flags", () => {
|
||||
const args = buildArgs("build", undefined, {
|
||||
minify: true,
|
||||
buildDrafts: true,
|
||||
cleanDestinationDir: false,
|
||||
});
|
||||
expect(args).toContain("--minify");
|
||||
// Flags with an explicit spec keep Hugo's canonical casing.
|
||||
expect(args).toContain("--buildDrafts");
|
||||
expect(args).not.toContain("--cleanDestinationDir");
|
||||
});
|
||||
});
|
||||
|
||||
describe("string flags", () => {
|
||||
it("should add flag with value", () => {
|
||||
const args = buildArgs("server", undefined, { port: 1313 });
|
||||
expect(args).toContain("--port");
|
||||
expect(args).toContain("1313");
|
||||
});
|
||||
|
||||
it("should keep spec flag casing (e.g. baseURL)", () => {
|
||||
const args = buildArgs("server", undefined, {
|
||||
baseURL: "http://localhost",
|
||||
});
|
||||
expect(args).toContain("--baseURL");
|
||||
expect(args).toContain("http://localhost");
|
||||
});
|
||||
|
||||
it("should convert unknown camelCase to kebab-case", () => {
|
||||
const args = buildArgs("build", undefined, {
|
||||
someUnknownFlag: "value",
|
||||
});
|
||||
expect(args).toContain("--some-unknown-flag");
|
||||
expect(args).toContain("value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("array flags", () => {
|
||||
it("should repeat flag for each array element", () => {
|
||||
const args = buildArgs("build", undefined, { theme: ["a", "b", "c"] });
|
||||
const themeIndices = args.reduce<number[]>((acc, arg, i) => {
|
||||
if (arg === "--theme") acc.push(i);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
expect(themeIndices).toHaveLength(3);
|
||||
expect(args[themeIndices[0] + 1]).toBe("a");
|
||||
expect(args[themeIndices[1] + 1]).toBe("b");
|
||||
expect(args[themeIndices[2] + 1]).toBe("c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex scenarios", () => {
|
||||
it("should handle mixed flags and positional args", () => {
|
||||
const args = buildArgs("new content", ["posts/my-post.md"], {
|
||||
kind: "post",
|
||||
force: true,
|
||||
editor: "vim",
|
||||
});
|
||||
|
||||
expect(args).toEqual([
|
||||
"new",
|
||||
"content",
|
||||
"posts/my-post.md",
|
||||
"--kind",
|
||||
"post",
|
||||
"--force",
|
||||
"--editor",
|
||||
"vim",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should skip undefined and null values", () => {
|
||||
const args = buildArgs("build", undefined, {
|
||||
minify: true,
|
||||
baseURL: undefined,
|
||||
destination: null,
|
||||
});
|
||||
|
||||
expect(args).toContain("--minify");
|
||||
expect(args).not.toContain("--base-u-r-l");
|
||||
expect(args).not.toContain("--destination");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expectTypeOf, it } from "vitest";
|
||||
import type { HugoCommand, HugoOptionsFor } from "../../src/generated/types";
|
||||
|
||||
describe("Type Safety", () => {
|
||||
it("should have correct command types", () => {
|
||||
// Check that specific commands are valid HugoCommand values
|
||||
expectTypeOf<"build">().toExtend<HugoCommand>();
|
||||
expectTypeOf<"server">().toExtend<HugoCommand>();
|
||||
expectTypeOf<"new site">().toExtend<HugoCommand>();
|
||||
expectTypeOf<"new theme">().toExtend<HugoCommand>();
|
||||
expectTypeOf<"new content">().toExtend<HugoCommand>();
|
||||
});
|
||||
|
||||
it("should map commands to correct option types", () => {
|
||||
type BuildOpts = HugoOptionsFor<"build">;
|
||||
type ServerOpts = HugoOptionsFor<"server">;
|
||||
type NewSiteOpts = HugoOptionsFor<"new site">;
|
||||
type NewThemeOpts = HugoOptionsFor<"new theme">;
|
||||
|
||||
// Build options should have minify
|
||||
expectTypeOf<BuildOpts>().toHaveProperty("minify");
|
||||
|
||||
// Server options should have port
|
||||
expectTypeOf<ServerOpts>().toHaveProperty("port");
|
||||
|
||||
// New site options should have format
|
||||
expectTypeOf<NewSiteOpts>().toHaveProperty("format");
|
||||
|
||||
// New theme options should have format
|
||||
expectTypeOf<NewThemeOpts>().toHaveProperty("format");
|
||||
});
|
||||
|
||||
it("should inherit global options", () => {
|
||||
type BuildOpts = HugoOptionsFor<"build">;
|
||||
|
||||
// All commands should have global options
|
||||
expectTypeOf<BuildOpts>().toHaveProperty("source");
|
||||
expectTypeOf<BuildOpts>().toHaveProperty("destination");
|
||||
expectTypeOf<BuildOpts>().toHaveProperty("environment");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/**/*.ts"],
|
||||
outDir: "dist",
|
||||
clean: true,
|
||||
dts: {
|
||||
resolve: true,
|
||||
},
|
||||
unbundle: true,
|
||||
copy: ["src/generated"],
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Run tests in Node environment
|
||||
environment: "node",
|
||||
|
||||
// Test file patterns
|
||||
include: ["tests/**/*.test.ts"],
|
||||
|
||||
// Coverage configuration
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/generated/**",
|
||||
"src/**/*.d.ts",
|
||||
"**/*.test.ts",
|
||||
"scripts/**",
|
||||
],
|
||||
},
|
||||
|
||||
// Timeout for integration tests (some may be slow)
|
||||
testTimeout: 30000,
|
||||
|
||||
// Vitest 4: avoid forks on macOS (can produce kill EPERM on teardown) and
|
||||
// keep files sequential to reduce contention around Hugo + temp dirs.
|
||||
pool: "threads",
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user