From 2d7c86c93c59e95e67eccd82d6800ffafc06b485 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 21 Aug 2026 14:18:37 -0700 Subject: [PATCH] refactor: Convert the build scripts to TypeScript and group them (#2696) - Closes: #2695 --- package.json | 2 +- scripts/dist-commands.js | 56 -------------- .../build-defines.ts} | 30 ++++++-- .../plugins/build-date.ts} | 8 +- .../plugins/clean-dist.ts} | 10 +-- .../plugins/facade-entry.ts} | 10 ++- .../plugins/svg-path.ts} | 11 ++- scripts/{ => vite}/public-entries.ts | 0 scripts/{ => vitest}/browsers.ts | 0 scripts/vitest/dist-commands.ts | 74 +++++++++++++++++++ tests/dist/dist.browser.test.ts | 2 +- tsconfig.json | 5 +- vite.config.ts | 12 +-- vitest.browser.config.ts | 4 +- vitest.config.ts | 2 +- vitest.dist.config.ts | 4 +- 16 files changed, 137 insertions(+), 93 deletions(-) delete mode 100644 scripts/dist-commands.js rename scripts/{build-defines.js => vite/build-defines.ts} (67%) rename scripts/{build-date-plugin.js => vite/plugins/build-date.ts} (88%) rename scripts/{clean-dist-plugin.js => vite/plugins/clean-dist.ts} (80%) rename scripts/{facade-entry-plugin.js => vite/plugins/facade-entry.ts} (91%) rename scripts/{svg-path-plugin.js => vite/plugins/svg-path.ts} (68%) rename scripts/{ => vite}/public-entries.ts (100%) rename scripts/{ => vitest}/browsers.ts (100%) create mode 100644 scripts/vitest/dist-commands.ts diff --git a/package.json b/package.json index 61db937b..957cd9fe 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/scripts/dist-commands.js b/scripts/dist-commands.js deleted file mode 100644 index 324938af..00000000 --- a/scripts/dist-commands.js +++ /dev/null @@ -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>} - */ -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; - }, -}; diff --git a/scripts/build-defines.js b/scripts/vite/build-defines.ts similarity index 67% rename from scripts/build-defines.js rename to scripts/vite/build-defines.ts index dd01b06e..9c224314 100644 --- a/scripts/build-defines.js +++ b/scripts/vite/build-defines.ts @@ -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 => { const gitHash = askGit('rev-parse', '--short', 'HEAD'); const developmentVersion = gitHash ? `dev+${gitHash}` : 'dev'; diff --git a/scripts/build-date-plugin.js b/scripts/vite/plugins/build-date.ts similarity index 88% rename from scripts/build-date-plugin.js rename to scripts/vite/plugins/build-date.ts index b516871b..6aaf7c35 100644 --- a/scripts/build-date-plugin.js +++ b/scripts/vite/plugins/build-date.ts @@ -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) { diff --git a/scripts/clean-dist-plugin.js b/scripts/vite/plugins/clean-dist.ts similarity index 80% rename from scripts/clean-dist-plugin.js rename to scripts/vite/plugins/clean-dist.ts index ca6757a9..ee43d63e 100644 --- a/scripts/clean-dist-plugin.js +++ b/scripts/vite/plugins/clean-dist.ts @@ -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) { diff --git a/scripts/facade-entry-plugin.js b/scripts/vite/plugins/facade-entry.ts similarity index 91% rename from scripts/facade-entry-plugin.js rename to scripts/vite/plugins/facade-entry.ts index e5377968..1cb464b0 100644 --- a/scripts/facade-entry-plugin.js +++ b/scripts/vite/plugins/facade-entry.ts @@ -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) { diff --git a/scripts/svg-path-plugin.js b/scripts/vite/plugins/svg-path.ts similarity index 68% rename from scripts/svg-path-plugin.js rename to scripts/vite/plugins/svg-path.ts index ab7f629f..79c2e06d 100644 --- a/scripts/svg-path-plugin.js +++ b/scripts/vite/plugins/svg-path.ts @@ -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. diff --git a/scripts/public-entries.ts b/scripts/vite/public-entries.ts similarity index 100% rename from scripts/public-entries.ts rename to scripts/vite/public-entries.ts diff --git a/scripts/browsers.ts b/scripts/vitest/browsers.ts similarity index 100% rename from scripts/browsers.ts rename to scripts/vitest/browsers.ts diff --git a/scripts/vitest/dist-commands.ts b/scripts/vitest/dist-commands.ts new file mode 100644 index 00000000..8f0eb8b1 --- /dev/null +++ b/scripts/vitest/dist-commands.ts @@ -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 => { + 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> = { + listDistFiles: () => listFiles(), + + getDistImportGraph: async () => { + const graph: Record = {}; + for (const file of listFiles().filter((name) => name.endsWith('.js'))) { + graph[file] = await getImports( + readFileSync(path.resolve(DIST_DIRECTORY, file), 'utf8'), + ); + } + return graph; + }, +}; diff --git a/tests/dist/dist.browser.test.ts b/tests/dist/dist.browser.test.ts index b4667803..394dcd6e 100644 --- a/tests/dist/dist.browser.test.ts +++ b/tests/dist/dist.browser.test.ts @@ -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'; diff --git a/tsconfig.json b/tsconfig.json index b61ef456..5f4ea661 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -60,5 +60,8 @@ } } ] - } + }, + + // `allowJs` would otherwise parse the build output. + "exclude": ["dist", "node_modules"] } diff --git a/vite.config.ts b/vite.config.ts index 1afbf6a0..e3d4aebe 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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`. diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 47e7dc3c..18d86221 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -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 diff --git a/vitest.config.ts b/vitest.config.ts index f4ef4b04..27224985 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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', diff --git a/vitest.dist.config.ts b/vitest.dist.config.ts index 22ea1bed..d03662a9 100644 --- a/vitest.dist.config.ts +++ b/vitest.dist.config.ts @@ -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.