feat: add bridgeable build cli
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -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.
|
||||
Executable
+8
@@ -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;
|
||||
});
|
||||
Generated
+1047
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
};
|
||||
@@ -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 || {})
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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]';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user