refactor: Replace Rollup with Vite (#2656)
This is a high risk change: every byte the card ships is emitted by a
different bundler, minified by a different minifier, and every
stylesheet is compiled by a different sass. The intent is that the
card's behaviour is unchanged.
**Significant development win**: Build time drops from 24s to 1.1s 🎉
Also adds a test suite ('dist') that runs against the built bundle.
This commit is contained in:
@@ -1,32 +1,36 @@
|
||||
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { readdirSync, rmSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Rollup plugin: deletes prior build artifacts from `dist/` (which otherwise
|
||||
* accumulate stale hashed chunks that can be served in place of fresh output).
|
||||
* Deletes only top-level build artifacts by extension (no recursion), so
|
||||
* anything unexpected in `dist/` survives. Cleans once per process: watch mode
|
||||
* cleans at startup, not on every incremental rebuild.
|
||||
* Vite plugin: removes build artifacts an earlier build left behind, which
|
||||
* otherwise accumulate indefinitely as the hashed names change.
|
||||
*
|
||||
* @type {() => import('rollup').Plugin}
|
||||
* 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.
|
||||
*
|
||||
* @type {() => import('vite').Plugin}
|
||||
*/
|
||||
export const cleanDist = () => {
|
||||
let cleaned = false;
|
||||
return {
|
||||
name: 'clean-dist',
|
||||
buildStart() {
|
||||
if (cleaned) {
|
||||
return;
|
||||
export const cleanDist = () => ({
|
||||
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));
|
||||
}
|
||||
cleaned = true;
|
||||
const dist = new URL('../dist/', import.meta.url);
|
||||
if (!existsSync(dist)) {
|
||||
return;
|
||||
}
|
||||
for (const entry of readdirSync(dist, { withFileTypes: true })) {
|
||||
if (entry.isFile() && /\.js(\.map)?$/.test(entry.name)) {
|
||||
rmSync(new URL(entry.name, dist));
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @type {(options: { publicFileNames: string[] }) => import('vite').Plugin}
|
||||
*/
|
||||
export const facadeEntry = ({ publicFileNames }) => ({
|
||||
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`,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* The files a Home Assistant dashboard resource may list.
|
||||
*
|
||||
* Everything else the build emits is hashed, so this is the one name that has to
|
||||
* stay put across releases.
|
||||
*/
|
||||
export const PUBLIC_ENTRY = 'advanced-camera-card.js';
|
||||
|
||||
/**
|
||||
* Both names a dashboard resource can name: the current one, and the one the
|
||||
* card shipped under previously, kept so an existing resource keeps working.
|
||||
*/
|
||||
export const PUBLIC_ENTRIES = [PUBLIC_ENTRY, 'frigate-hass-card.js'];
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Vite plugin: `import style from './foo.scss'` gives the compiled CSS as a
|
||||
* string, which is what the source tree passes to Lit's `unsafeCSS`. Left
|
||||
* alone, Vite adds those styles to the page and the import returns nothing
|
||||
* useful. Appending Vite's own `?inline` asks for the text instead, compiled by
|
||||
* the same sass the build uses.
|
||||
*
|
||||
* The build's `rollup-plugin-styler` cannot be used here: Vite always handles
|
||||
* `.scss` itself, so it would take that plugin's JavaScript output and hand it
|
||||
* to sass, which rejects it.
|
||||
*
|
||||
* @type {() => import('vite').Plugin}
|
||||
*/
|
||||
export const scssString = () => ({
|
||||
name: 'scss-string',
|
||||
|
||||
// Vite: run before its builtin CSS handling.
|
||||
enforce: 'pre',
|
||||
|
||||
async resolveId(source, importer, options) {
|
||||
if (!source.endsWith('.scss')) {
|
||||
return null;
|
||||
}
|
||||
return await this.resolve(`${source}?inline`, importer, options);
|
||||
},
|
||||
});
|
||||
@@ -1,16 +1,16 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Rollup/Vite plugin: 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.
|
||||
* Vite plugin: 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.
|
||||
*
|
||||
* @type {() => import('rollup').Plugin}
|
||||
* @type {() => import('vite').Plugin}
|
||||
*/
|
||||
export const svgPath = () => ({
|
||||
name: 'svg-path',
|
||||
|
||||
// Vite: run before its builtin asset handling.
|
||||
// Run before Vite's builtin asset handling.
|
||||
enforce: 'pre',
|
||||
|
||||
load(id) {
|
||||
|
||||
Reference in New Issue
Block a user