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
+1 -1
View File
@@ -178,7 +178,7 @@
"docs-update-images": "./scripts/docs-update-images.sh",
"docs-update-dependencies": "./scripts/docs-update-dependencies.sh",
"docs-update-uml": "docker run --init --rm -u \"$(id -u):$(id -g)\" -e HOME=/tmp -e JAVA_TOOL_OPTIONS=-Duser.home=/tmp -v \"$(pwd):/data\" -w /data plantuml/plantuml -tsvg docs/uml/call-sequence.puml -o /data/docs/images",
"lint": "eslint '{src,tests}/**/*.ts'",
"lint": "eslint '{scripts,src,tests}/**/*.ts'",
"typecheck": "tsc --noEmit -p tsconfig.json",
"format": "prettier --write .",
"format-check": "prettier --check .",
-56
View File
@@ -1,56 +0,0 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { init, parse } from 'es-module-lexer';
const DIST_DIRECTORY = 'dist';
const listFiles = () =>
existsSync(DIST_DIRECTORY)
? readdirSync(DIST_DIRECTORY, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
: [];
/**
* What a file imports, split into static imports, which the browser fetches
* before the file runs, and dynamic ones, which it fetches only when reached.
*/
const getImports = async (source) => {
await init;
const [imports] = parse(source);
// `n` is the file named in the import, and is absent when that name is put
// together at runtime, which names no one file.
const named = imports.filter((entry) => entry.n);
// `d` says how it is imported: -1 static, -2 `import.meta`, otherwise the
// position of the `import(`.
return {
staticImports: named.filter((entry) => entry.d === -1).map((entry) => entry.n),
dynamicImports: named.filter((entry) => entry.d >= 0).map((entry) => entry.n),
};
};
/**
* Facts about the built output, gathered in Node and handed to a test running
* in the browser.
*
* The imports are parsed rather than searched for: the card holds its own
* filenames as plain strings, which a text search would mistake for imports.
*
* @type {Record<string, import('vitest/node').BrowserCommand<[], unknown>>}
*/
export const distCommands = {
listDistFiles: () => listFiles(),
getDistImportGraph: async () => {
const graph = {};
for (const file of listFiles().filter((name) => name.endsWith('.js'))) {
graph[file] = await getImports(
readFileSync(path.resolve(DIST_DIRECTORY, file), 'utf8'),
);
}
return graph;
},
};
@@ -1,12 +1,12 @@
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { BUILD_DATE_PLACEHOLDER } from './build-date-plugin.js';
import { BUILD_DATE_PLACEHOLDER } from './plugins/build-date.js';
/**
* Asks git something or gives back nothing when it cannot be asked.
*/
const askGit = (...args) => {
const askGit = (...args: string[]): string => {
try {
return execFileSync('git', args, {
encoding: 'utf-8',
@@ -17,8 +17,25 @@ const askGit = (...args) => {
}
};
const getPackageVersion = () =>
JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).version;
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.
@@ -33,7 +50,10 @@ const getPackageVersion = () =>
*
* The values are JSON so that a bundler can drop them in as written.
*/
export const getBuildDefines = ({ dev, releaseVersion }) => {
export const getBuildDefines = ({
dev,
releaseVersion,
}: BuildDefinesOptions): Record<string, string> => {
const gitHash = askGit('rev-parse', '--short', 'HEAD');
const developmentVersion = gitHash ? `dev+${gitHash}` : 'dev';
@@ -1,7 +1,9 @@
/**
* Vite plugin: writes the date of the build into the card.
* 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___';
@@ -11,10 +13,8 @@ export const BUILD_DATE_PLACEHOLDER = '__BUILD_DATE_SENTINEL___';
* 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.
*
* @type {() => import('vite').Plugin}
*/
export const buildDate = () => ({
export const buildDate = (): Plugin => ({
name: 'build-date',
renderChunk(code) {
@@ -1,19 +1,19 @@
import { readdirSync, rmSync } from 'node:fs';
import path from 'node:path';
import type { Plugin } from 'vite';
/**
* Vite plugin: removes build artifacts an earlier build left behind, which
* otherwise accumulate indefinitely as the hashed names change.
* 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.
*
* @type {() => import('vite').Plugin}
*/
export const cleanDist = () => ({
export const cleanDist = (): Plugin => ({
name: 'clean-dist',
writeBundle(options, bundle) {
@@ -1,3 +1,9 @@
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.
@@ -8,10 +14,8 @@
* 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 }) => ({
export const facadeEntry = ({ publicFileNames }: FacadeEntryOptions): Plugin => ({
name: 'facade-entry',
generateBundle(_options, bundle) {
@@ -1,13 +1,12 @@
import { readFileSync } from 'node:fs';
import type { Plugin } from 'vite';
/**
* 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('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.
*/
export const svgPath = () => ({
export const svgPath = (): Plugin => ({
name: 'svg-path',
// Run before Vite's builtin asset handling.
+74
View File
@@ -0,0 +1,74 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { init, parse } from 'es-module-lexer';
import type { BrowserCommand } from 'vitest/node';
const DIST_DIRECTORY = 'dist';
const listFiles = (): string[] =>
existsSync(DIST_DIRECTORY)
? readdirSync(DIST_DIRECTORY, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
: [];
interface FileImports {
staticImports: string[];
dynamicImports: string[];
}
const STATIC_IMPORT = -1;
/**
* What a file imports, split into static imports, which the browser fetches
* before the file runs, and dynamic ones, which it fetches only when reached.
*
* es-module-lexer names its fields tersely. `n` is the imported path, and is
* absent when a dynamic import builds its path at runtime (e.g. `import('./' +
* name)`), so no one file can be recorded for it. `d` is the position of a
* dynamic import's `import(`, or -1 for a static import and -2 for
* `import.meta`, which is not an import of a file at all.
*/
const getImports = async (source: string): Promise<FileImports> => {
await init;
const [imports] = parse(source);
const staticImports: string[] = [];
const dynamicImports: string[] = [];
for (const { n: importedPath, d: dynamicImportPosition } of imports) {
if (importedPath === undefined) {
continue;
}
if (dynamicImportPosition === STATIC_IMPORT) {
staticImports.push(importedPath);
} else if (dynamicImportPosition >= 0) {
dynamicImports.push(importedPath);
}
}
return { staticImports, dynamicImports };
};
/**
* Facts about the built output, gathered in Node and handed to a test running
* in the browser.
*
* The imports are parsed rather than searched for: the card holds its own
* filenames as plain strings, which a text search would mistake for imports.
*/
export const distCommands: Record<string, BrowserCommand<[], unknown>> = {
listDistFiles: () => listFiles(),
getDistImportGraph: async () => {
const graph: Record<string, FileImports> = {};
for (const file of listFiles().filter((name) => name.endsWith('.js'))) {
graph[file] = await getImports(
readFileSync(path.resolve(DIST_DIRECTORY, file), 'utf8'),
);
}
return graph;
},
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { commands } from 'vitest/browser';
import { PUBLIC_ENTRIES, PUBLIC_ENTRY } from '../../scripts/public-entries.js';
import { PUBLIC_ENTRIES, PUBLIC_ENTRY } from '../../scripts/vite/public-entries.js';
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
import type { FakeHASS } from '../browser/fake-hass';
import { defineHAElementStubs } from '../browser/ha-element-stubs';
+4 -1
View File
@@ -60,5 +60,8 @@
}
}
]
}
},
// `allowJs` would otherwise parse the build output.
"exclude": ["dist", "node_modules"]
}
+6 -6
View File
@@ -1,12 +1,12 @@
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import { buildDate } from './scripts/build-date-plugin.js';
import { getBuildDefines } from './scripts/build-defines.js';
import { cleanDist } from './scripts/clean-dist-plugin.js';
import { facadeEntry } from './scripts/facade-entry-plugin.js';
import { PUBLIC_ENTRIES } from './scripts/public-entries.js';
import { svgPath } from './scripts/svg-path-plugin.js';
import { getBuildDefines } from './scripts/vite/build-defines.js';
import { buildDate } from './scripts/vite/plugins/build-date.js';
import { cleanDist } from './scripts/vite/plugins/clean-dist.js';
import { facadeEntry } from './scripts/vite/plugins/facade-entry.js';
import { svgPath } from './scripts/vite/plugins/svg-path.js';
import { PUBLIC_ENTRIES } from './scripts/vite/public-entries.js';
// `yarn start` passes `--mode development`; a plain `vite build` is
// `production`.
+2 -2
View File
@@ -1,8 +1,8 @@
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
import { getBrowsers, type Browser } from './scripts/browsers.js';
import { svgPath } from './scripts/svg-path-plugin.js';
import { svgPath } from './scripts/vite/plugins/svg-path.js';
import { getBrowsers, type Browser } from './scripts/vitest/browsers.js';
// Browser tests mount the real card in a browser. Their own config rather than
// a fourth project in `vitest.config.ts`, because `vitest run` executes every
+1 -1
View File
@@ -3,7 +3,7 @@ import { join } from 'path';
import { defineConfig } from 'vitest/config';
import { svgPath } from './scripts/svg-path-plugin.js';
import { svgPath } from './scripts/vite/plugins/svg-path.js';
const EXCLUSIONS = [
'.eslintrc.cjs',
+2 -2
View File
@@ -1,8 +1,8 @@
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
import { getBrowsers, type Browser } from './scripts/browsers.js';
import { distCommands } from './scripts/dist-commands.js';
import { getBrowsers, type Browser } from './scripts/vitest/browsers.js';
import { distCommands } from './scripts/vitest/dist-commands.js';
// This is the only test suite that runs against the built bundle rather than
// `src/`. They need a build to already exist: run `yarn run build` first.