refactor: Convert the build scripts to TypeScript and group them (#2696)

- Closes: #2695
This commit is contained in:
Dermot Duffy
2026-08-21 14:18:37 -07:00
committed by GitHub
parent 1e03e6a3d9
commit 2d7c86c93c
16 changed files with 137 additions and 93 deletions
+40
View File
@@ -0,0 +1,40 @@
/**
* Writes the date of the build into the card.
*/
import type { Plugin } from 'vite';
// Strict length requirement: See below.
export const BUILD_DATE_PLACEHOLDER = '__BUILD_DATE_SENTINEL___';
/**
* The date cannot be handed to the bundler as a substituted name, because the
* build configuration is evaluated once and `vite build --watch` reuses it.
* As such, every rebuild would report the time the watcher started rather than
* the actual build time. Output is generated afresh for each build, so the date
* is written here instead.
*/
export const buildDate = (): Plugin => ({
name: 'build-date',
renderChunk(code) {
if (!code.includes(BUILD_DATE_PLACEHOLDER)) {
return null;
}
const date = new Date().toISOString();
if (date.length !== BUILD_DATE_PLACEHOLDER.length) {
this.error(
'The build date must be exactly as long as the placeholder it replaces.',
);
}
return {
code: code.replaceAll(BUILD_DATE_PLACEHOLDER, date),
// The replacement is exactly as long as what it replaces, so the existing
// sourcemap still describes the code.
map: null,
};
},
});
+36
View File
@@ -0,0 +1,36 @@
import { readdirSync, rmSync } from 'node:fs';
import path from 'node:path';
import type { Plugin } from 'vite';
/**
* Removes build artifacts an earlier build left behind, which otherwise accumulate
* indefinitely as the hashed names change.
*
* Runs once the new output is on disk, and keeps whatever this build just
* wrote, rather than emptying the directory beforehand. This ensures the card
* is never briefly missing from a directory Home Assistant is potentially
* serving out of. Only the build's own kind of file is removed, by extension
* and without recursing, so anything else there survives.
*/
export const cleanDist = (): Plugin => ({
name: 'clean-dist',
writeBundle(options, bundle) {
if (!options.dir) {
return;
}
const written = new Set(Object.keys(bundle));
for (const entry of readdirSync(options.dir, { withFileTypes: true })) {
if (
entry.isFile() &&
/\.js(\.map)?$/.test(entry.name) &&
!written.has(entry.name)
) {
rmSync(path.resolve(options.dir, entry.name));
}
}
},
});
+61
View File
@@ -0,0 +1,61 @@
import type { Plugin } from 'vite';
interface FacadeEntryOptions {
publicFileNames: string[];
}
/**
* Emits the files a dashboard resource can name, each a re-export of the hashed
* chunk holding the actual card.
*
* HACS registers the card as a dashboard resource with a `hacstag` query
* parameter on the URL, and the browser treats a different URL as a different
* file. Were a chunk to import the untagged name, the card would be fetched and
* run a second time, and defining its elements twice will throw an error.
* Keeping the card itself in a hashed chunk that nothing outside can name is
* what prevents that, and the check below is what keeps it true.
*/
export const facadeEntry = ({ publicFileNames }: FacadeEntryOptions): Plugin => ({
name: 'facade-entry',
generateBundle(_options, bundle) {
const entries = Object.values(bundle).filter(
(item) => item.type === 'chunk' && item.isEntry,
);
if (entries.length !== 1) {
throw new Error(`Expected exactly one entry chunk, found ${entries.length}`);
}
const [entry] = entries;
// A public name already in the bundle means the card was emitted under it
// rather than into a hashed chunk, which is the arrangement this plugin
// exists to prevent.
const alreadyEmitted = publicFileNames.filter((name) => name in bundle);
if (alreadyEmitted.length) {
throw new Error(
`The build should have emitted these under hashed names: ${alreadyEmitted.join(', ')}`,
);
}
const offenders = Object.values(bundle).flatMap((item) =>
item.type === 'chunk'
? [...item.imports, ...item.dynamicImports]
.filter((imported) => publicFileNames.includes(imported))
.map((imported) => `${item.fileName} -> ${imported}`)
: [],
);
if (offenders.length) {
throw new Error(
`Chunks should not import a public entry point: ${offenders.join(', ')}`,
);
}
for (const fileName of publicFileNames) {
this.emitFile({
type: 'asset',
fileName,
source: `export * from './${entry.fileName}';\n`,
});
}
},
});
+27
View File
@@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs';
import type { Plugin } from 'vite';
/**
* Importing an SVG yields `{ path, viewBox }` extracted at build time, the shape a
* Home Assistant custom iconset serves. The SVG must be a single-path icon.
*/
export const svgPath = (): Plugin => ({
name: 'svg-path',
// Run before Vite's builtin asset handling.
enforce: 'pre',
load(id) {
if (!id.endsWith('.svg')) {
return null;
}
const source = readFileSync(id, 'utf-8');
const path = / d="([^"]*)"/.exec(source)?.[1];
const viewBox = /viewBox="([^"]*)"/.exec(source)?.[1];
if (!path || !viewBox) {
throw new Error(`Imported SVG must have a path and viewBox: ${id}`);
}
return `export default ${JSON.stringify({ path, viewBox })};`;
},
});