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