diff --git a/README.md b/README.md index 2a21574..414fc91 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/file-rules.js b/src/file-rules.js index acbd852..759e1b5 100644 --- a/src/file-rules.js +++ b/src/file-rules.js @@ -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) => { diff --git a/src/vite-config.js b/src/vite-config.js index c477e03..7a40779 100644 --- a/src/vite-config.js +++ b/src/vite-config.js @@ -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); } });