| | |
| | | |
| | | import { spawn } from "node:child_process"; |
| | | import { constants } from "node:fs"; |
| | | import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; |
| | | import { |
| | | access, |
| | | copyFile, |
| | | cp, |
| | | mkdir, |
| | | mkdtemp, |
| | | readFile, |
| | | rm, |
| | | stat, |
| | | writeFile, |
| | | } from "node:fs/promises"; |
| | | import { tmpdir } from "node:os"; |
| | | import { join } from "node:path"; |
| | | import { basename, dirname, join, resolve } from "node:path"; |
| | | import { fileURLToPath } from "node:url"; |
| | | |
| | | /** |
| | | * Absolute path to the theme repository root (the parent of `scripts/`). |
| | | */ |
| | | const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); |
| | | |
| | | interface CommandResult { |
| | | code: number | null; |
| | |
| | | expectedFiles?: string[]; |
| | | } |
| | | |
| | | /** |
| | | * Where the theme under test comes from. |
| | | * |
| | | * - `local`: install the local working tree of this repository (default), so the |
| | | * test exercises the actual code on the current branch, including uncommitted |
| | | * changes. This is what makes the test meaningful as a pre-push / CI gate. |
| | | * - `submodule`: clone the published theme from its remote via `git submodule add`, |
| | | * reproducing the documented quickstart install. Useful to verify the public |
| | | * install path, but does **not** see local changes. |
| | | */ |
| | | type ThemeSource = "local" | "submodule"; |
| | | |
| | | interface RoutineOptions { |
| | | projectName: string; |
| | | themeSource: ThemeSource; |
| | | themePath: string; |
| | | themeRepo: string; |
| | | themeDir: string; |
| | | themeName: string; |
| | |
| | | |
| | | const DEFAULT_OPTIONS: RoutineOptions = { |
| | | projectName: "quickstart", |
| | | themeSource: "local", |
| | | themePath: REPO_ROOT, |
| | | themeRepo: "https://github.com/gohugo-ananke/ananke.git", |
| | | themeDir: "themes/ananke", |
| | | themeName: "ananke", |
| | |
| | | |
| | | Options: |
| | | --project-name=<name> Hugo project folder name inside the temp directory |
| | | --theme-repo=<url> Git URL for the theme submodule |
| | | --theme-path=<path> Install the theme from this local directory (default: this repo). |
| | | Implies local mode; tests the actual working tree. |
| | | --use-submodule Install the published theme via "git submodule add" instead |
| | | of the local working tree (verifies the documented quickstart). |
| | | --theme-repo=<url> Git URL for the theme submodule (only used with --use-submodule) |
| | | --theme-dir=<path> Theme target directory inside the project |
| | | --theme-name=<name> Theme name written into hugo.toml |
| | | --config-file=<file> Hugo config file to update |
| | |
| | | continue; |
| | | } |
| | | |
| | | if (arg === "--use-submodule") { |
| | | options.themeSource = "submodule"; |
| | | continue; |
| | | } |
| | | |
| | | if (arg.startsWith("--theme-path=")) { |
| | | options.themePath = resolve(arg.slice("--theme-path=".length)); |
| | | options.themeSource = "local"; |
| | | continue; |
| | | } |
| | | |
| | | if (arg.startsWith("--project-name=")) { |
| | | options.projectName = arg.slice("--project-name=".length); |
| | | continue; |
| | |
| | | } |
| | | |
| | | /** |
| | | * Determine whether a directory is the work tree of a Git repository. |
| | | * |
| | | * @param path Absolute directory path. |
| | | * @returns True when `path` is inside a Git work tree. |
| | | */ |
| | | async function isGitWorkTree(path: string): Promise<boolean> { |
| | | const result = await runCommand( |
| | | "git", |
| | | ["-C", path, "rev-parse", "--is-inside-work-tree"], |
| | | path, |
| | | ); |
| | | |
| | | return result.code === 0 && result.stdout.trim() === "true"; |
| | | } |
| | | |
| | | /** |
| | | * Copy the local theme working tree into the project's theme directory. |
| | | * |
| | | * When the source is a Git work tree, the file list is derived from Git so that |
| | | * ignored paths (`node_modules`, `public`, generated resources, ...) are skipped |
| | | * automatically while uncommitted and untracked-but-not-ignored changes are still |
| | | * included. This makes the test reflect the exact state of the current branch. |
| | | * |
| | | * @param themePath Absolute path to the local theme source directory. |
| | | * @param destination Absolute path to the theme directory inside the project. |
| | | * @throws Error when the source contains no theme files. |
| | | */ |
| | | async function copyLocalTheme( |
| | | themePath: string, |
| | | destination: string, |
| | | ): Promise<void> { |
| | | if (await isGitWorkTree(themePath)) { |
| | | const listing = await runCommand( |
| | | "git", |
| | | [ |
| | | "-C", |
| | | themePath, |
| | | "ls-files", |
| | | "-z", |
| | | "--cached", |
| | | "--others", |
| | | "--exclude-standard", |
| | | ], |
| | | themePath, |
| | | ); |
| | | |
| | | if (listing.code !== 0) { |
| | | throw new Error( |
| | | `Failed to list theme files via git in ${themePath}:\n${listing.stderr}`, |
| | | ); |
| | | } |
| | | |
| | | const relativePaths = listing.stdout.split("\0").filter(Boolean); |
| | | |
| | | if (relativePaths.length === 0) { |
| | | throw new Error(`No theme files found in ${themePath}`); |
| | | } |
| | | |
| | | for (const relativePath of relativePaths) { |
| | | const source = join(themePath, relativePath); |
| | | |
| | | try { |
| | | const stats = await stat(source); |
| | | if (!stats.isFile()) { |
| | | continue; |
| | | } |
| | | } catch { |
| | | // Tracked but deleted in the work tree: nothing to copy. |
| | | continue; |
| | | } |
| | | |
| | | const target = join(destination, relativePath); |
| | | await mkdir(dirname(target), { recursive: true }); |
| | | await copyFile(source, target); |
| | | } |
| | | |
| | | return; |
| | | } |
| | | |
| | | // Fallback for a non-Git source directory: copy recursively while excluding |
| | | // heavy or generated paths that would never ship with the theme. |
| | | const excludedNames = new Set(["node_modules", "public", ".git"]); |
| | | await cp(themePath, destination, { |
| | | recursive: true, |
| | | filter: (source: string): boolean => { |
| | | if (excludedNames.has(basename(source))) { |
| | | return false; |
| | | } |
| | | |
| | | return !source.includes(join("resources", "_gen")); |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Install the theme into the temporary project, either from the local working |
| | | * tree (default) or from the published remote via a Git submodule. |
| | | * |
| | | * @param options Runtime options. |
| | | * @param projectRoot Absolute path to the temporary quickstart project. |
| | | * @param reports Accumulated step reports (appended to in submodule mode). |
| | | * @throws Error when installation fails or the theme is incomplete. |
| | | */ |
| | | async function installTheme( |
| | | options: RoutineOptions, |
| | | projectRoot: string, |
| | | reports: StepReport[], |
| | | ): Promise<void> { |
| | | const destination = join(projectRoot, options.themeDir); |
| | | |
| | | if (options.verbose) { |
| | | console.log(`\n[RUN] Install theme (source: ${options.themeSource})`); |
| | | } |
| | | |
| | | if (options.themeSource === "submodule") { |
| | | const step: StepDefinition = { |
| | | name: "Add theme as Git submodule", |
| | | command: "git", |
| | | args: ["submodule", "add", options.themeRepo, options.themeDir], |
| | | cwd: projectRoot, |
| | | expectedFiles: [options.themeDir, ".gitmodules"], |
| | | }; |
| | | const report = await executeStep(step); |
| | | reports.push(report); |
| | | |
| | | if (options.verbose) { |
| | | console.log( |
| | | `[OK ] ${step.name} (${report.result.durationMs} ms, exit ${String(report.result.code)})`, |
| | | ); |
| | | } |
| | | |
| | | return; |
| | | } |
| | | |
| | | const started = Date.now(); |
| | | await copyLocalTheme(options.themePath, destination); |
| | | |
| | | // Sanity check: a usable theme must at least expose theme.toml and layouts. |
| | | await assertFileExists(join(destination, "theme.toml")); |
| | | await assertFileExists(join(destination, "layouts")); |
| | | |
| | | if (options.verbose) { |
| | | console.log( |
| | | `[OK ] Copied local theme from ${options.themePath} (${Date.now() - started} ms)`, |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Run a list of command steps with consistent logging and reporting. |
| | | * |
| | | * @param steps Steps to execute in order. |
| | | * @param options Runtime options. |
| | | * @param projectRoot Absolute path to the temporary quickstart project. |
| | | * @param reports Accumulated step reports (appended to). |
| | | */ |
| | | async function runSteps( |
| | | steps: StepDefinition[], |
| | | options: RoutineOptions, |
| | | projectRoot: string, |
| | | reports: StepReport[], |
| | | ): Promise<void> { |
| | | for (const step of steps) { |
| | | if (options.verbose) { |
| | | console.log(`\n[RUN] ${step.name}`); |
| | | console.log(` ${formatCommand(step.command, step.args)}`); |
| | | } |
| | | |
| | | const report = isHugoBuildCommand(step) |
| | | ? await executeHugoBuildStep(step, projectRoot) |
| | | : await executeStep(step); |
| | | |
| | | reports.push(report); |
| | | |
| | | if (options.verbose) { |
| | | console.log( |
| | | `[OK ] ${step.name} (${report.result.durationMs} ms, exit ${String(report.result.code)})`, |
| | | ); |
| | | |
| | | const trimmedOutput = report.result.combined.trim(); |
| | | if (trimmedOutput) { |
| | | console.log(trimmedOutput); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Run the full Hugo quickstart verification routine. |
| | | * |
| | | * @param options Runtime options. |
| | |
| | | |
| | | const reports: StepReport[] = []; |
| | | |
| | | const steps: StepDefinition[] = [ |
| | | // Steps that prepare the project before the theme is installed. |
| | | const setupSteps: StepDefinition[] = [ |
| | | { |
| | | name: "Create Hugo project", |
| | | command: "hugo", |
| | |
| | | cwd: projectRoot, |
| | | expectedFiles: [".git"], |
| | | }, |
| | | { |
| | | name: "Add theme as Git submodule", |
| | | command: "git", |
| | | args: ["submodule", "add", options.themeRepo, options.themeDir], |
| | | cwd: projectRoot, |
| | | expectedFiles: [options.themeDir, ".gitmodules"], |
| | | }, |
| | | ]; |
| | | |
| | | // Steps that run once the theme is in place. |
| | | const buildSteps: StepDefinition[] = [ |
| | | { |
| | | name: "Configure theme in Hugo config", |
| | | command: "bash", |
| | |
| | | try { |
| | | console.log(`Test root: ${sandboxRoot}`); |
| | | console.log(`Project root: ${projectRoot}`); |
| | | console.log( |
| | | options.themeSource === "submodule" |
| | | ? `Theme source: submodule (${options.themeRepo})` |
| | | : `Theme source: local (${options.themePath})`, |
| | | ); |
| | | |
| | | for (const step of steps) { |
| | | if (options.verbose) { |
| | | console.log(`\n[RUN] ${step.name}`); |
| | | console.log(` ${formatCommand(step.command, step.args)}`); |
| | | } |
| | | |
| | | const report = isHugoBuildCommand(step) |
| | | ? await executeHugoBuildStep(step, projectRoot) |
| | | : await executeStep(step); |
| | | |
| | | reports.push(report); |
| | | |
| | | if (options.verbose) { |
| | | console.log( |
| | | `[OK ] ${step.name} (${report.result.durationMs} ms, exit ${String(report.result.code)})`, |
| | | ); |
| | | |
| | | const trimmedOutput = report.result.combined.trim(); |
| | | if (trimmedOutput) { |
| | | console.log(trimmedOutput); |
| | | } |
| | | } |
| | | } |
| | | await runSteps(setupSteps, options, projectRoot, reports); |
| | | await installTheme(options, projectRoot, reports); |
| | | await runSteps(buildSteps, options, projectRoot, reports); |
| | | |
| | | const configPath = join(projectRoot, options.configFile); |
| | | const homepagePath = join(projectRoot, "public/index.html"); |