Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/theme-build-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astryxdesign/cli': patch
---

[feat] `astryx theme build --watch`: rebuild a theme automatically whenever the source file changes, until interrupted with Ctrl-C. Removes the manual re-run step (and the stale-CSS confusion that comes with forgetting it) from the theme-authoring loop. Each rebuild runs in a child process so a build error is contained and the watcher keeps running. Not supported with `--json`. (#3375)
117 changes: 117 additions & 0 deletions packages/cli/src/commands/build-theme.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import {pathToFileURL, fileURLToPath} from 'node:url';
import {spawn} from 'node:child_process';
import {createJiti} from 'jiti';
import {getRunPrefix} from '../utils/package-manager.mjs';
import {
Expand Down Expand Up @@ -597,6 +598,104 @@ function validatePrivateVars(themeDef) {
return errors;
}

/**
* Path to this CLI's real entry (bin/astryx.mjs), resolved from this module's
* location (src/commands/build-theme.mjs → ../../bin/astryx.mjs). Used to
* re-invoke `theme build` as a child process in watch mode.
*/
function resolveCliBin() {
const commandsDir = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(commandsDir, '../../bin/astryx.mjs');
}

/**
* Run a single `theme build` as a child process, reusing the exact
* single-build code path (and its error handling) rather than duplicating it.
* Resolves with the child's exit code; never rejects.
*
* @param {string} file - The theme file argument, as the user passed it.
* @param {object} options - Parsed command options (only `out` is forwarded).
* @returns {Promise<number>}
*/
function runThemeBuildOnceChild(file, options) {
const cliBin = resolveCliBin();
const args = [cliBin, 'theme', 'build', file];
if (options.out) args.push('--out', options.out);
return new Promise(resolve => {
const child = spawn(process.execPath, args, {
stdio: 'inherit',
env: process.env,
});
child.on('close', code => resolve(code ?? 0));
child.on('error', () => resolve(1));
});
}

/**
* Watch a theme file and rebuild on change. Runs an initial build, then
* rebuilds (debounced) whenever the file changes, until interrupted with
* Ctrl-C. Each rebuild runs in a child process so a build error (which the
* single-build path reports via a hard exit) is contained and the watcher
* keeps running.
*
* @param {string} file - The theme file argument, as the user passed it.
* @param {string} filePath - Absolute path to the theme file.
* @param {object} options - Parsed command options.
* @returns {Promise<void>} Resolves when the watcher is stopped (Ctrl-C).
*/
async function runThemeBuildWatch(file, filePath, options) {
const rel = path.relative(process.cwd(), filePath);

// Initial build.
await runThemeBuildOnceChild(file, options);

humanLog(`\n👀 Watching ${rel} for changes — press Ctrl-C to stop.`);

let building = false;
let queued = false;
let debounce = null;

const rebuild = async () => {
if (building) {
// Coalesce changes that land mid-build into a single follow-up run.
queued = true;
return;
}
building = true;
humanLog(`\n♻️ Change detected — rebuilding ${rel}...`);
await runThemeBuildOnceChild(file, options);
building = false;
humanLog(`\n👀 Watching ${rel} for changes — press Ctrl-C to stop.`);
if (queued) {
queued = false;
rebuild();
}
};

// Some editors replace the file (rename) rather than writing in place, which
// can drop the watch. Watch the containing directory and filter to our file
// so edits survive atomic-save/rename.
const watchDir = path.dirname(filePath);
const baseName = path.basename(filePath);
const watcher = fs.watch(watchDir, (_eventType, changed) => {
if (changed && changed !== baseName) return;
clearTimeout(debounce);
// Debounce: editors often emit several events per save.
debounce = setTimeout(rebuild, 100);
});

await new Promise(resolve => {
const stop = () => {
clearTimeout(debounce);
watcher.close();
humanLog('\nStopped watching.');
resolve();
};
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
});
}

export function registerTheme(program) {
const theme = program
.command('theme')
Expand All @@ -621,6 +720,10 @@ export function registerTheme(program) {
.command('build <file>')
.description('Compile a defineTheme file to CSS + JS')
.option('-o, --out <path>', 'Output CSS file path')
.option(
'-w, --watch',
'Rebuild automatically when the theme file changes (Ctrl-C to stop)',
)
.action(async (file, options) => {
const filePath = path.resolve(process.cwd(), file);
const json = program.opts().json || false;
Expand All @@ -630,6 +733,20 @@ export function registerTheme(program) {
return;
}

// Watch mode: run an initial build, then rebuild on every change to the
// theme file. Watch is a human-interactive, long-running mode — it is not
// supported in --json (machine) mode, which expects a single envelope.
if (options.watch) {
if (json) {
cliError('--watch is not supported with --json', {
code: ERROR_CODES.ERR_THEME_INVALID,
});
return;
}
await runThemeBuildWatch(file, filePath, options);
return;
}

if (!json) humanLog(`\nBuilding theme from ${path.relative(process.cwd(), filePath)}...`);

// Extract theme definition
Expand Down
157 changes: 157 additions & 0 deletions packages/cli/src/commands/build-theme.watch.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file Tests for `astryx theme build --watch` (#3375).
*
* Watch mode runs an initial build, then rebuilds whenever the theme file
* changes, until interrupted. Each rebuild runs in a child process so a build
* error (reported by the single-build path via a hard exit) is contained and
* the watcher keeps running.
*
* Building `astryx theme build` requires a compiled @astryxdesign/core, so this
* suite builds core once in beforeAll (mirrors build-theme.prose.test.mjs).
*/

import {describe, it, expect, beforeAll, beforeEach, afterEach} from 'vitest';
import {execFileSync, spawn} from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {fileURLToPath} from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CLI_BIN = path.resolve(__dirname, '../../bin/astryx.mjs');
const REPO_ROOT = path.resolve(__dirname, '../../../..');
const CORE_THEME_ENTRY = path.join(
REPO_ROOT,
'packages/core/dist/theme/index.js',
);

function runCli(args, cwd) {
try {
const out = execFileSync('node', [CLI_BIN, ...args], {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
env: {...process.env, FORCE_COLOR: '0'},
});
return {code: 0, stdout: out, stderr: ''};
} catch (e) {
return {
code: e.status ?? 1,
stdout: e.stdout?.toString() ?? '',
stderr: e.stderr?.toString() ?? '',
};
}
}

/** Poll until `predicate()` is true or the timeout elapses. */
async function waitFor(predicate, {timeout = 8000, interval = 50} = {}) {
const start = Date.now();
for (;;) {
if (predicate()) return true;
if (Date.now() - start > timeout) return false;
await new Promise(r => setTimeout(r, interval));
}
}

beforeAll(() => {
if (!fs.existsSync(CORE_THEME_ENTRY)) {
execFileSync('pnpm', ['-F', '@astryxdesign/core', 'build'], {
cwd: REPO_ROOT,
stdio: 'pipe',
timeout: 180_000,
});
}
}, 200_000);

let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-build-theme-watch-'));
});
afterEach(() => {
fs.rmSync(tmpDir, {recursive: true, force: true});
});

describe('theme build --watch', () => {
it('advertises the flag in --help', () => {
const result = runCli(['theme', 'build', '--help'], process.cwd());
const out = result.stdout + result.stderr;
expect(out).toMatch(/--watch/);
});

it('rejects --watch together with --json (single-envelope contract)', () => {
const themeFile = path.join(tmpDir, 'wt.mjs');
fs.writeFileSync(
themeFile,
`export default { name: 'wt', tokens: { '--color-bg': '#fff' } };\n`,
);
const result = runCli(
['--json', 'theme', 'build', path.relative(tmpDir, themeFile), '--watch'],
tmpDir,
);
expect(result.code).not.toBe(0);
expect(result.stdout + result.stderr).toMatch(/watch/i);
});

it('builds initially, rebuilds on change, and stops cleanly on SIGINT', async () => {
const themeFile = path.join(tmpDir, 'wt.mjs');
const cssFile = path.join(tmpDir, 'wt.css');
fs.writeFileSync(
themeFile,
`export default { name: 'wt', tokens: { '--color-bg': '#ffffff' } };\n`,
);

const child = spawn(
process.execPath,
[CLI_BIN, 'theme', 'build', 'wt.mjs', '--watch'],
{cwd: tmpDir, env: {...process.env, FORCE_COLOR: '0'}},
);
let stdout = '';
child.stdout.on('data', d => (stdout += d.toString()));
child.stderr.on('data', d => (stdout += d.toString()));

try {
// Initial build produces the CSS.
const built = await waitFor(() => fs.existsSync(cssFile));
expect(built).toBe(true);
const firstCss = fs.readFileSync(cssFile, 'utf-8');
expect(firstCss).toMatch(/#ffffff/);

// Wait until the watcher is actually watching before editing, so the
// change isn't missed.
await waitFor(() => /Watching/i.test(stdout));

// Change the theme — the token value changes so the CSS must change.
fs.writeFileSync(
themeFile,
`export default { name: 'wt', tokens: { '--color-bg': '#010203' } };\n`,
);

// The rebuilt CSS should reflect the new value.
const rebuilt = await waitFor(() => {
try {
return fs.readFileSync(cssFile, 'utf-8').includes('#010203');
} catch {
return false;
}
});
expect(rebuilt).toBe(true);
expect(stdout).toMatch(/rebuild/i);
} finally {
// SIGINT must stop the watcher and exit cleanly.
child.kill('SIGINT');
}

const exited = await new Promise(resolve => {
let done = false;
child.on('exit', () => {
done = true;
resolve(true);
});
setTimeout(() => resolve(done), 4000);
});
expect(exited).toBe(true);
expect(stdout).toMatch(/Stopped watching/);
}, 30_000);
});
Loading