Emit hashed static assets in Bridgeable builds

This commit is contained in:
2026-06-05 13:34:17 -06:00
parent 8055a15ea3
commit c19d0d7182
3 changed files with 123 additions and 12 deletions
+1
View File
@@ -53,6 +53,7 @@ The CLI preserves the current Bridgeable Components production contract:
- 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`.
- Static image, font, and video assets are emitted as hashed files and added to `manifest.json`.
- `*.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.
+13 -10
View File
@@ -5,6 +5,18 @@ export const normalizePath = (path) => path.replaceAll('\\', '/');
const styleExtensions = ['.scss', '.css'];
export const staticAssetExtensions = [
'.gif',
'.jpeg',
'.jpg',
'.mp4',
'.png',
'.svg',
'.webp',
'.woff',
'.woff2'
];
export const shouldSkipDirectory = (name) =>
['.git', 'dist', 'docs', 'node_modules', 'storybook-static'].includes(name);
@@ -34,18 +46,9 @@ export const shouldCopyFile = (name) => {
'.css',
'.scss',
'.gz',
'.gif',
'.jpeg',
'.jpg',
'.js',
'.mp4',
'.png',
'.svg',
'.webp',
'.woff',
'.woff2',
'.zip'
].includes(extname(name));
].concat(staticAssetExtensions).includes(extname(name));
};
export const walkFiles = (directory, callback) => {
+109 -2
View File
@@ -1,7 +1,112 @@
import { defineConfig } from 'vite';
import { createHash } from 'node:crypto';
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
import { collectEntries, normalizePath, shouldCopyFile, walkFiles } from './file-rules.js';
import { collectEntries, normalizePath, shouldCopyFile, staticAssetExtensions, walkFiles } from './file-rules.js';
const staticAssetExtensionSet = new Set(staticAssetExtensions);
const isStaticAssetFile = (absolutePath) => staticAssetExtensionSet.has(extname(absolutePath).toLowerCase());
const hashedAssetPath = (relativePath, contents) => {
const extension = extname(relativePath);
const stem = relativePath.slice(0, -extension.length);
const hash = createHash('sha256').update(contents).digest('base64url').slice(0, 8);
return `${stem}.${hash}${extension}`;
};
const emitStaticAssetsWithManifest = ({ sourceDir, outDir }) => {
const manifestPath = join(outDir, 'manifest.json');
const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {};
let changed = false;
walkFiles(sourceDir, (absolutePath) => {
if (!isStaticAssetFile(absolutePath)) {
return;
}
const relativePath = normalizePath(relative(sourceDir, absolutePath));
if (manifest[relativePath]?.file) {
return;
}
const contents = readFileSync(absolutePath);
const file = hashedAssetPath(relativePath, contents);
const destination = join(outDir, file);
mkdirSync(dirname(destination), { recursive: true });
writeFileSync(destination, contents);
manifest[relativePath] = {
file,
src: relativePath
};
changed = true;
});
if (changed) {
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
}
};
const manifestRelativeAssetFromUrl = (assetUrl, manifest) => {
const cleanUrl = assetUrl.split('#')[0].split('?')[0].replaceAll('\\', '/');
const path = cleanUrl.startsWith('/') ? cleanUrl.slice(1) : cleanUrl;
if (manifest[path]?.file) {
return path;
}
const bridgeableMatch = path.match(/(?:^|\/)bridgeable-components\/(.+)$/);
if (bridgeableMatch?.[1] && manifest[bridgeableMatch[1]]?.file) {
return bridgeableMatch[1];
}
for (const prefix of ['assets/', 'components/', 'sections/', 'styles/', 'tokens/']) {
if (path.startsWith(prefix) && manifest[path]?.file) {
return path;
}
}
return '';
};
const rewriteCssManifestAssetUrls = (outDir) => {
const manifestPath = join(outDir, 'manifest.json');
if (!existsSync(manifestPath)) {
return;
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
walkFiles(outDir, (absolutePath) => {
if (!absolutePath.endsWith('.css')) {
return;
}
const cssDir = dirname(absolutePath);
const contents = readFileSync(absolutePath, 'utf8');
const rewritten = contents.replace(
/url\((['"]?)(?!data:|https?:|\/\/)([^'")]+)\1\)/g,
(match, quote, assetUrl) => {
const relativeAsset = manifestRelativeAssetFromUrl(assetUrl, manifest);
const builtFile = relativeAsset ? manifest[relativeAsset]?.file : '';
if (!builtFile) {
return match;
}
const relativeUrl = normalizePath(relative(cssDir, join(outDir, builtFile)));
return `url(${quote}${relativeUrl}${quote})`;
}
);
if (rewritten !== contents) {
writeFileSync(absolutePath, rewritten);
}
});
};
const normalizeScssManifestEntries = (outDir) => {
const manifestPath = join(outDir, 'manifest.json');
@@ -69,7 +174,7 @@ const copyServerFilesPlugin = ({ sourceDir, outDir }) => ({
name: 'copy-bridgeable-server-files',
buildStart() {
walkFiles(sourceDir, (absolutePath) => {
if (shouldCopyFile(basename(absolutePath))) {
if (shouldCopyFile(basename(absolutePath)) || isStaticAssetFile(absolutePath)) {
this.addWatchFile(absolutePath);
}
});
@@ -89,6 +194,8 @@ const copyServerFilesPlugin = ({ sourceDir, outDir }) => ({
});
normalizeScssManifestEntries(outDir);
emitStaticAssetsWithManifest({ sourceDir, outDir });
rewriteCssManifestAssetUrls(outDir);
normalizeCssAssetUrls(outDir);
}
});