refactor: Convert the build scripts to TypeScript and group them (#2696)
- Closes: #2695
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { BUILD_DATE_PLACEHOLDER } from './plugins/build-date.js';
|
||||
|
||||
/**
|
||||
* Asks git something or gives back nothing when it cannot be asked.
|
||||
*/
|
||||
const askGit = (...args: string[]): string => {
|
||||
try {
|
||||
return execFileSync('git', args, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getPackageVersion = (): string => {
|
||||
const contents: unknown = JSON.parse(
|
||||
readFileSync(new URL('../../package.json', import.meta.url), 'utf-8'),
|
||||
);
|
||||
if (
|
||||
typeof contents !== 'object' ||
|
||||
contents === null ||
|
||||
!('version' in contents) ||
|
||||
typeof contents.version !== 'string'
|
||||
) {
|
||||
throw new Error('package.json must have a string version');
|
||||
}
|
||||
return contents.version;
|
||||
};
|
||||
|
||||
interface BuildDefinesOptions {
|
||||
dev: boolean;
|
||||
releaseVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the build stamps into the card, as names for the bundler to substitute.
|
||||
*
|
||||
* `releaseVersion` is the version being released, which only the release
|
||||
* workflow knows; a build without one reports the version in `package.json`, or
|
||||
* a development build the commit it was made from. Its presence is what makes a
|
||||
* build a released one.
|
||||
*
|
||||
* The build date is a placeholder here, written in for by the `buildDate`
|
||||
* plugin as each build's output is generated.
|
||||
*
|
||||
* The values are JSON so that a bundler can drop them in as written.
|
||||
*/
|
||||
export const getBuildDefines = ({
|
||||
dev,
|
||||
releaseVersion,
|
||||
}: BuildDefinesOptions): Record<string, string> => {
|
||||
const gitHash = askGit('rev-parse', '--short', 'HEAD');
|
||||
const developmentVersion = gitHash ? `dev+${gitHash}` : 'dev';
|
||||
|
||||
return {
|
||||
__ADVANCED_CAMERA_CARD_RELEASE_VERSION__: JSON.stringify(
|
||||
releaseVersion ?? (dev ? developmentVersion : getPackageVersion()),
|
||||
),
|
||||
__ADVANCED_CAMERA_CARD_IS_RELEASE_BUILD__: JSON.stringify(!!releaseVersion),
|
||||
__ADVANCED_CAMERA_CARD_GIT_HASH__: JSON.stringify(gitHash),
|
||||
__ADVANCED_CAMERA_CARD_GIT_DATE__: JSON.stringify(
|
||||
askGit('log', '-1', '--format=%cI'),
|
||||
),
|
||||
__ADVANCED_CAMERA_CARD_BUILD_DATE__: JSON.stringify(BUILD_DATE_PLACEHOLDER),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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 })};`;
|
||||
},
|
||||
});
|
||||
@@ -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'];
|
||||
Reference in New Issue
Block a user