feat: add bridgeable build cli

This commit is contained in:
2026-04-28 15:32:23 -06:00
commit 6d160ba49f
12 changed files with 1567 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
+58
View File
@@ -0,0 +1,58 @@
# Bridgeable Build
Shared production build CLI for Bridgeable Components source libraries.
The source library should contain author-owned files only:
```text
assets/
components/
sections/
styles/
tokens/
bridgeable.config.json
```
Build tooling, dependency versions, Vite configuration, manifest generation, and artifact packing live here instead of inside each client component repository.
## Commands
Validate a source library:
```bash
bridgeable-build validate --source .
```
Build a production `dist` directory:
```bash
bridgeable-build build --source . --out ./dist
```
Pack an existing `dist` directory:
```bash
bridgeable-build pack --dist ./dist --out ./bridgeable-components.zip
```
Build and pack in one CI-style command:
```bash
bridgeable-build publish \
--source . \
--out ./dist \
--artifact ./bridgeable-components.zip
```
## Build Contract
The CLI preserves the current Bridgeable Components production contract:
- `styles/global.css` is bundled as the shared global stylesheet.
- `*.component.json` files under `components/` and `sections/` are discovered automatically.
- Section CSS and JS are emitted as standalone hashed assets.
- Component CSS is expected to be imported by global CSS or section CSS.
- Component JS is emitted only when it self-registers with `window.initBlock`.
- `*.twig` and `*.component.json` files are copied into the output.
- Storybook-only files, fixture data, docs, build config, `node_modules`, `.git`, and previous build output are excluded.
- Vite emits `manifest.json`, which the WordPress Bridgeable Components plugin uses to resolve hashed asset filenames.
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env node
import { runCli } from '../src/cli.js';
runCli(process.argv).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
+1047
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@axe-web/bridgeable-build",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"bridgeable-build": "./bin/bridgeable-build.js"
},
"scripts": {
"build": "node ./bin/bridgeable-build.js build",
"validate": "node ./bin/bridgeable-build.js validate"
},
"dependencies": {
"vite": "^7.3.2"
},
"engines": {
"node": ">=20"
}
}
+6
View File
@@ -0,0 +1,6 @@
import { build as viteBuild } from 'vite';
import { createViteConfig } from './vite-config.js';
export const buildSource = async ({ sourceDir, outDir, config }) => {
await viteBuild(createViteConfig({ sourceDir, outDir, config }));
};
+121
View File
@@ -0,0 +1,121 @@
import { resolve } from 'node:path';
import { loadBridgeableConfig } from './config.js';
import { validateSource } from './validate.js';
import { buildSource } from './build.js';
import { packDist } from './pack.js';
const usage = `Usage:
bridgeable-build validate --source <dir>
bridgeable-build build --source <dir> --out <dir>
bridgeable-build pack --dist <dir> --out <file>
bridgeable-build publish --source <dir> --out <dir> --artifact <file>
`;
const parseArgs = (argv) => {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith('--')) {
continue;
}
const key = token.slice(2);
const value = argv[i + 1];
if (!value || value.startsWith('--')) {
args[key] = true;
continue;
}
args[key] = value;
i += 1;
}
return args;
};
const printValidationResult = (result) => {
for (const warning of result.warnings) {
console.warn(`Warning: ${warning}`);
}
for (const error of result.errors) {
console.error(`Error: ${error}`);
}
if (result.valid) {
console.log(`Bridgeable source is valid. Component specs found: ${result.specCount}`);
}
};
const requireArg = (args, key) => {
if (!args[key]) {
throw new Error(`Missing required option --${key}\n\n${usage}`);
}
return String(args[key]);
};
const validateOrThrow = (sourceDir, config) => {
const result = validateSource(sourceDir, config);
printValidationResult(result);
if (!result.valid) {
throw new Error('Bridgeable source validation failed.');
}
return result;
};
export const runCli = async (processArgv) => {
const [, , command = 'help', ...rawArgs] = processArgv;
const args = parseArgs(rawArgs);
if (command === 'help' || args.help) {
console.log(usage);
return;
}
if (command === 'pack') {
packDist({
distDir: requireArg(args, 'dist'),
outFile: requireArg(args, 'out')
});
console.log(`Packed ${resolve(String(args.out))}`);
return;
}
const sourceDir = resolve(requireArg(args, 'source'));
const config = loadBridgeableConfig(sourceDir);
if (command === 'validate') {
const result = validateSource(sourceDir, config);
printValidationResult(result);
if (!result.valid) {
process.exitCode = 1;
}
return;
}
if (command === 'build') {
validateOrThrow(sourceDir, config);
await buildSource({
sourceDir,
outDir: resolve(args.out ? String(args.out) : 'dist'),
config
});
return;
}
if (command === 'publish') {
const outDir = resolve(requireArg(args, 'out'));
validateOrThrow(sourceDir, config);
await buildSource({ sourceDir, outDir, config });
packDist({
distDir: outDir,
outFile: requireArg(args, 'artifact')
});
return;
}
throw new Error(`Unknown command: ${command}\n\n${usage}`);
};
+37
View File
@@ -0,0 +1,37 @@
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
const DEFAULT_CONFIG = {
schemaVersion: 1,
type: 'bridgeable-components',
paths: {
assets: 'assets',
components: 'components',
sections: 'sections',
styles: 'styles',
tokens: 'tokens'
},
entry: {
globalStyles: 'styles/global.css'
}
};
export const loadBridgeableConfig = (sourceDir) => {
const configPath = join(resolve(sourceDir), 'bridgeable.config.json');
const userConfig = existsSync(configPath)
? JSON.parse(readFileSync(configPath, 'utf8'))
: {};
return {
...DEFAULT_CONFIG,
...userConfig,
paths: {
...DEFAULT_CONFIG.paths,
...(userConfig.paths || {})
},
entry: {
...DEFAULT_CONFIG.entry,
...(userConfig.entry || {})
}
};
};
+108
View File
@@ -0,0 +1,108 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
export const normalizePath = (path) => path.replaceAll('\\', '/');
export const shouldSkipDirectory = (name) =>
['.git', 'dist', 'docs', 'node_modules', 'storybook-static'].includes(name);
export const shouldCopyFile = (name) => {
if (name.startsWith('.')) {
return false;
}
if (name.endsWith('.stories.js') || name.endsWith('.data.js')) {
return false;
}
if (
[
'README.md',
'bridgeable.config.json',
'package.json',
'package-lock.json',
'vite.config.js'
].includes(name)
) {
return false;
}
return ![
'.css',
'.gif',
'.jpeg',
'.jpg',
'.js',
'.mp4',
'.png',
'.svg',
'.webp',
'.woff',
'.woff2'
].includes(extname(name));
};
export const walkFiles = (directory, callback) => {
if (!existsSync(directory)) {
return;
}
for (const entry of readdirSync(directory)) {
const absolutePath = join(directory, entry);
const stats = statSync(absolutePath);
if (stats.isDirectory()) {
if (!shouldSkipDirectory(entry)) {
walkFiles(absolutePath, callback);
}
continue;
}
callback(absolutePath);
}
};
export const hasRuntimeSelfRegistration = (absolutePath) =>
readFileSync(absolutePath, 'utf8').includes('window.initBlock');
export const collectEntries = (sourceDir, config) => {
const sourceRoot = resolve(sourceDir);
const entries = {};
const globalCss = join(sourceRoot, config.entry.globalStyles);
if (existsSync(globalCss)) {
entries[normalizePath(config.entry.globalStyles)] = globalCss;
}
walkFiles(sourceRoot, (absolutePath) => {
if (!absolutePath.endsWith('.component.json')) {
return;
}
const componentDir = dirname(absolutePath);
const stem = basename(absolutePath, '.component.json');
const relativeSpecPath = normalizePath(relative(sourceRoot, absolutePath));
const isComponentSpec = relativeSpecPath.startsWith(`${config.paths.components}/`);
for (const extension of ['.css', '.js']) {
if (isComponentSpec && extension === '.css') {
continue;
}
const entryPath = join(componentDir, `${stem}${extension}`);
if (!existsSync(entryPath)) {
continue;
}
if (isComponentSpec && extension === '.js' && !hasRuntimeSelfRegistration(entryPath)) {
continue;
}
const relativePath = normalizePath(relative(sourceRoot, entryPath));
entries[extension === '.js' ? relativePath.slice(0, -extension.length) : relativePath] =
entryPath;
}
});
return entries;
};
+28
View File
@@ -0,0 +1,28 @@
import { existsSync, mkdirSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
export const packDist = ({ distDir, outFile }) => {
const absoluteDist = resolve(distDir);
const absoluteOut = resolve(outFile);
if (!existsSync(absoluteDist)) {
throw new Error(`Cannot pack missing dist directory: ${absoluteDist}`);
}
mkdirSync(dirname(absoluteOut), { recursive: true });
rmSync(absoluteOut, { force: true });
const result = spawnSync('zip', ['-qr', absoluteOut, '.'], {
cwd: absoluteDist,
stdio: 'inherit'
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`zip exited with status ${result.status}`);
}
};
+77
View File
@@ -0,0 +1,77 @@
import { existsSync, readFileSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import { walkFiles } from './file-rules.js';
const allowedSchemaVersions = new Set([1]);
const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
export const validateSource = (sourceDir, config) => {
const sourceRoot = resolve(sourceDir);
const errors = [];
const warnings = [];
if (!allowedSchemaVersions.has(config.schemaVersion)) {
errors.push(`Unsupported schemaVersion: ${config.schemaVersion}`);
}
if (config.type !== 'bridgeable-components') {
errors.push(`Unsupported type: ${config.type}`);
}
for (const [key, path] of Object.entries(config.paths)) {
if (!path || typeof path !== 'string') {
errors.push(`paths.${key} must be a non-empty string.`);
continue;
}
if (!existsSync(join(sourceRoot, path))) {
warnings.push(`Configured path is missing: ${path}`);
}
}
if (!existsSync(join(sourceRoot, config.entry.globalStyles))) {
warnings.push(`Global style entry is missing: ${config.entry.globalStyles}`);
}
let specCount = 0;
walkFiles(sourceRoot, (absolutePath) => {
if (!absolutePath.endsWith('.component.json')) {
return;
}
specCount += 1;
const relativePath = relative(sourceRoot, absolutePath);
let spec;
try {
spec = readJson(absolutePath);
} catch (error) {
errors.push(`Invalid JSON in ${relativePath}: ${error.message}`);
return;
}
const expectedType = relativePath.startsWith(`${config.paths.components}/`)
? 'component'
: 'section';
if (spec.type && spec.type !== expectedType) {
errors.push(`${relativePath} has type "${spec.type}" but expected "${expectedType}".`);
}
if (!spec.name || typeof spec.name !== 'string') {
errors.push(`${relativePath} must define a string "name".`);
}
});
if (specCount === 0) {
warnings.push('No *.component.json files were found.');
}
return {
valid: errors.length === 0,
errors,
warnings,
specCount
};
};
+57
View File
@@ -0,0 +1,57 @@
import { defineConfig } from 'vite';
import { cpSync, mkdirSync } from 'node:fs';
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
import { collectEntries, normalizePath, shouldCopyFile, walkFiles } from './file-rules.js';
const copyServerFilesPlugin = ({ sourceDir, outDir }) => ({
name: 'copy-bridgeable-server-files',
closeBundle() {
walkFiles(sourceDir, (absolutePath) => {
const relativePath = normalizePath(relative(sourceDir, absolutePath));
const fileName = basename(absolutePath);
if (!shouldCopyFile(fileName)) {
return;
}
const destination = join(outDir, relativePath);
mkdirSync(dirname(destination), { recursive: true });
cpSync(absolutePath, destination);
});
}
});
export const createViteConfig = ({ sourceDir, outDir, config }) => {
const sourceRoot = resolve(sourceDir);
const outputRoot = resolve(outDir);
return defineConfig({
root: sourceRoot,
base: './',
publicDir: false,
plugins: [copyServerFilesPlugin({ sourceDir: sourceRoot, outDir: outputRoot })],
build: {
outDir: outputRoot,
emptyOutDir: true,
manifest: 'manifest.json',
rollupOptions: {
input: collectEntries(sourceRoot, config),
output: {
entryFileNames: '[name].[hash].js',
chunkFileNames: 'chunks/[name].[hash].js',
assetFileNames: (assetInfo) => {
const originalFileName = assetInfo.originalFileNames?.[0];
if (originalFileName) {
const extension = extname(originalFileName);
const stem = originalFileName.slice(0, -extension.length);
return `${stem}.[hash][extname]`;
}
return 'assets/[name].[hash][extname]';
}
}
}
}
});
};