diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1d1cb6c..fd93ceb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,9 +30,6 @@ jobs: - name: 📥 Download deps uses: bahmutov/npm-install@v1 - - name: 🖼 Build icons - run: npm run build:icons - - name: 🔬 Lint run: npm run lint @@ -54,9 +51,6 @@ jobs: - name: 📥 Download deps uses: bahmutov/npm-install@v1 - - name: 🖼 Build icons - run: npm run build:icons - - name: 🔎 Type check run: npm run typecheck --if-present @@ -75,9 +69,6 @@ jobs: - name: 📥 Download deps uses: bahmutov/npm-install@v1 - - name: 🖼 Build icons - run: npm run build:icons - - name: 🏗 Build run: npm run build diff --git a/.gitignore b/.gitignore index 1641e4d..d618829 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ node_modules .DS_store /build -/server-build .env .cache diff --git a/.prettierignore b/.prettierignore index f022d02..9dc17fc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,7 +2,6 @@ node_modules /build /public/build -/server-build .env /test-results/ diff --git a/app/routes/_seo+/sitemap[.]xml.ts b/app/routes/_seo+/sitemap[.]xml.ts index 8fd216b..3fac073 100644 --- a/app/routes/_seo+/sitemap[.]xml.ts +++ b/app/routes/_seo+/sitemap[.]xml.ts @@ -9,10 +9,10 @@ export function loader({ request }: Route.LoaderArgs) { // generateSitemap needs React Router's server route manifest. It used to // arrive as `context.serverBuild`, but React Router 8 requires // getLoadContext to return a RouterContextProvider, and a createContext() - // symbol cannot be shared between server/index.ts and the app because - // esbuild and Vite produce separate module graphs, so each side would hold - // a different context object. Reading the manifest straight out of the - // server build keeps it inside the app's own module graph instead. + // symbol cannot be shared between server/index.ts and the app because Node + // and Vite produce separate module graphs, so each side would hold a + // different context object. Reading the manifest straight out of the server + // build keeps it inside the app's own module graph instead. return generateSitemap(request, serverRoutes, { siteUrl: getDomainUrl(request), headers: { diff --git a/eslint.config.js b/eslint.config.js index a56df7e..3e866aa 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,11 @@ export default [ }, }, { - ignores: ['.react-router/*'], + // Generated, gitignored, and absent in CI, so linting them here would + // only ever see files no other machine has. tsconfig picks the sprite's + // types up (it is a real module now), so a leftover .d.ts from the + // script this replaced would otherwise fail lint as a file "not found + // by the project service". + ignores: ['.react-router/*', 'app/components/ui/icons/*'], }, ] diff --git a/index.js b/index.ts similarity index 69% rename from index.js rename to index.ts index ec390d6..f04afe6 100644 --- a/index.js +++ b/index.ts @@ -16,8 +16,6 @@ sourceMapSupport.install({ }, }) -if (process.env.NODE_ENV === 'production') { - await import('./server-build/index.js') -} else { - await import('./server/index.ts') -} +// Node strips the types off this and the rest of the server graph, so there is +// no compiled copy of it to pick between in development and production. +await import('./server/index.ts') diff --git a/other/Dockerfile b/other/Dockerfile index dc634fd..02e7464 100644 --- a/other/Dockerfile +++ b/other/Dockerfile @@ -54,7 +54,6 @@ WORKDIR /myapp COPY --from=production-deps /myapp/node_modules /myapp/node_modules -COPY --from=build /myapp/server-build /myapp/server-build COPY --from=build /myapp/build /myapp/build COPY --from=build /myapp/package.json /myapp/package.json COPY --from=build /myapp/app/components/ui/icons /myapp/app/components/ui/icons diff --git a/other/build-icons.ts b/other/build-icons.ts deleted file mode 100644 index ccaaf1a..0000000 --- a/other/build-icons.ts +++ /dev/null @@ -1,153 +0,0 @@ -import * as path from 'node:path' -import { $ } from 'execa' -import fsExtra from 'fs-extra' -import { glob } from 'glob' -import { parse } from 'node-html-parser' - -const cwd = process.cwd() -const inputDir = path.join(cwd, 'other', 'svg-icons') -const inputDirRelative = path.relative(cwd, inputDir) -const outputDir = path.join(cwd, 'app', 'components', 'ui', 'icons') -await fsExtra.ensureDir(outputDir) - -const files = glob - .sync('**/*.svg', { - cwd: inputDir, - }) - .sort((a, b) => a.localeCompare(b)) - -const shouldVerboseLog = process.argv.includes('--log=verbose') -const logVerbose = shouldVerboseLog ? console.log : () => {} - -if (files.length === 0) { - console.log(`No SVG files found in ${inputDirRelative}`) -} else { - await generateIconFiles() -} - -async function generateIconFiles() { - const spriteFilepath = path.join(outputDir, 'sprite.svg') - const typeOutputFilepath = path.join(outputDir, 'name.d.ts') - const currentSprite = await fsExtra - .readFile(spriteFilepath, 'utf8') - .catch(() => '') - const currentTypes = await fsExtra - .readFile(typeOutputFilepath, 'utf8') - .catch(() => '') - - const iconNames = files.map((file) => iconName(file)) - - const spriteUpToDate = iconNames.every((name) => - currentSprite.includes(`id=${name}`), - ) - const typesUpToDate = iconNames.every((name) => - currentTypes.includes(`"${name}"`), - ) - - if (spriteUpToDate && typesUpToDate) { - logVerbose(`Icons are up to date`) - return - } - - logVerbose(`Generating sprite for ${inputDirRelative}`) - - const spriteChanged = await generateSvgSprite({ - files, - inputDir, - outputPath: spriteFilepath, - }) - - for (const file of files) { - logVerbose('✅', file) - } - logVerbose(`Saved to ${path.relative(cwd, spriteFilepath)}`) - - const stringifiedIconNames = iconNames.map((name) => JSON.stringify(name)) - - const typeOutputContent = `// This file is generated by npm run build:icons - -export type IconName = -\t| ${stringifiedIconNames.join('\n\t| ')}; -` - const typesChanged = await writeIfChanged( - typeOutputFilepath, - typeOutputContent, - ) - - logVerbose(`Manifest saved to ${path.relative(cwd, typeOutputFilepath)}`) - - const readmeChanged = await writeIfChanged( - path.join(outputDir, 'README.md'), - `# Icons - -This directory contains SVG icons that are used by the app. - -Everything in this directory is generated by \`npm run build:icons\`. -`, - ) - - if (spriteChanged || typesChanged || readmeChanged) { - console.log(`Generated ${files.length} icons`) - } -} - -function iconName(file: string) { - return file.replace(/\.svg$/, '') -} - -/** - * Creates a single SVG file that contains all the icons - */ -async function generateSvgSprite({ - files, - inputDir, - outputPath, -}: { - files: string[] - inputDir: string - outputPath: string -}) { - // Each SVG becomes a symbol and we wrap them all in a single SVG - const symbols = await Promise.all( - files.map(async (file) => { - const input = await fsExtra.readFile(path.join(inputDir, file), 'utf8') - const root = parse(input) - - const svg = root.querySelector('svg') - if (!svg) throw new Error('No SVG element found') - - svg.tagName = 'symbol' - svg.setAttribute('id', iconName(file)) - svg.removeAttribute('xmlns') - svg.removeAttribute('xmlns:xlink') - svg.removeAttribute('version') - svg.removeAttribute('width') - svg.removeAttribute('height') - - return svg.toString().trim() - }), - ) - - const output = [ - ``, - ``, - ``, - ``, // for semantics: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs - ...symbols, - ``, - ``, - '', // trailing newline - ].join('\n') - - return writeIfChanged(outputPath, output) -} - -async function writeIfChanged(filepath: string, newContent: string) { - const currentContent = await fsExtra - .readFile(filepath, 'utf8') - .catch(() => '') - if (currentContent === newContent) return false - await fsExtra.writeFile(filepath, newContent, 'utf8') - await $`prettier --write ${filepath} --ignore-unknown` - return true -} diff --git a/other/build-server.ts b/other/build-server.ts deleted file mode 100644 index 1cbcdd6..0000000 --- a/other/build-server.ts +++ /dev/null @@ -1,72 +0,0 @@ -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import esbuild from 'esbuild' -import fsExtra from 'fs-extra' -import { globSync } from 'glob' - -const pkg = fsExtra.readJsonSync(path.join(process.cwd(), 'package.json')) - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const here = (...s: Array) => path.join(__dirname, ...s) -const globsafe = (s: string) => s.replace(/\\/g, '/') - -const allFiles = globSync(globsafe(here('../server/**/*.*')), { - ignore: [ - 'server/dev-server.js', // for development only - '**/tsconfig.json', - '**/eslint*', - '**/__tests__/**', - ], -}) - -const entries = [] -for (const file of allFiles) { - if (/\.(ts|js|tsx|jsx)$/.test(file)) { - entries.push(file) - } else { - const dest = file.replace(here('../server'), here('../server-build')) - fsExtra.ensureDirSync(path.parse(dest).dir) - fsExtra.copySync(file, dest) - console.log(`copied: ${file.replace(`${here('../server')}/`, '')}`) - } -} - -console.log() -console.log('building...') - -// esbuild's node target must be a single X[.Y[.Z]] version, but engines.node -// is a semver range. Take the lowest version the range admits so the output -// still runs on the oldest supported runtime. -function lowestSupportedNode(range: string) { - const parts = [...range.matchAll(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/g)] - .map(([, major, minor, patch]) => ({ - major: Number(major), - minor: minor === undefined ? 0 : Number(minor), - patch: patch === undefined ? 0 : Number(patch), - })) - .sort((a, b) => a.major - b.major || a.minor - b.minor || a.patch - b.patch) - - const lowest = parts[0] - if (!lowest) { - throw new Error(`Could not read a version out of engines.node: ${range}`) - } - return `${lowest.major}.${lowest.minor}.${lowest.patch}` -} - -const nodeTarget = `node${lowestSupportedNode(pkg.engines.node)}` -console.log(`target: ${nodeTarget}`) - -esbuild - .build({ - entryPoints: entries, - outdir: here('../server-build'), - target: [nodeTarget], - platform: 'node', - sourcemap: true, - format: 'esm', - logLevel: 'info', - }) - .catch((error: unknown) => { - console.error(error) - process.exit(1) - }) diff --git a/other/sly/sly.json b/other/sly/sly.json index af7a28c..9e8f9da 100644 --- a/other/sly/sly.json +++ b/other/sly/sly.json @@ -4,7 +4,6 @@ { "name": "tabler-icons", "directory": "./other/svg-icons", - "postinstall": ["npm", "run", "build:icons"], "transformers": ["transform-icon.ts"] } ] diff --git a/other/svg-icons/README.md b/other/svg-icons/README.md index a9be9dd..b6a2829 100644 --- a/other/svg-icons/README.md +++ b/other/svg-icons/README.md @@ -5,7 +5,9 @@ under MIT: https://github.com/radix-ui/icons/blob/master/LICENSE It's important that you only add icons to this directory that the application actually needs as there's no "tree-shaking" for sprites. If you wish to manually -split up your SVG sprite into multiple files, you'll need to update the -`build-icons.ts` script to do that. +split up your SVG sprite into multiple files, you'll need to pass a second +config to `iconsSpritesheet` in `vite.config.ts` to do that. -Run `npm run build:icons` to update the sprite. +The sprite and the icon name types are regenerated by the Vite plugin on every +build, on `npm run dev`, and whenever a file in this directory is added, changed +or deleted. There is no separate script to run. diff --git a/package-lock.json b/package-lock.json index 409bc22..50e63bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@react-router/express": "^8.4.0", "@react-router/node": "^8.4.0", "@reactuses/core": "^6.5.9", + "@sentry/node": "^11.0.0", "@sentry/profiling-node": "^11.0.0", "@sentry/react": "^11.0.0", "@tailwindcss/postcss": "^4.3.3", @@ -32,7 +33,6 @@ "cross-env": "^10.1.0", "date-fns": "^4.4.0", "dotenv": "^17.2.3", - "execa": "^10.0.1", "express": "^5.2.1", "express-rate-limit": "^8.7.0", "get-port": "^7.2.0", @@ -71,7 +71,6 @@ "@types/compression": "^1.8.1", "@types/eslint": "^9.6.1", "@types/express": "^5.0.6", - "@types/fs-extra": "^11.0.4", "@types/morgan": "^1.9.10", "@types/node": "^25.9.8", "@types/nodemailer": "^8.0.2", @@ -81,14 +80,10 @@ "@vitejs/plugin-react": "^5.1.2", "@vitest/ui": "^5.0.2", "babel-plugin-react-compiler": "^1.0.0", - "esbuild": "^0.25.11", "eslint": "^10.11.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-compiler": "^19.1.0-rc.2", - "fs-extra": "^11.4.1", - "glob": "^13.0.6", "jsdom": "^30.1.1", - "node-html-parser": "^7.0.2", "npm-run-all": "^4.1.5", "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.7.4", @@ -97,6 +92,7 @@ "typescript": "^6.0.3", "vite": "^7.3.6", "vite-env-only": "^3.0.3", + "vite-plugin-icons-spritesheet": "^3.1.0", "vitest": "^5.0.2" }, "engines": { @@ -1005,448 +1001,6 @@ "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -2850,11 +2404,6 @@ "win32" ] }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==" - }, "node_modules/@sentry/browser": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-11.0.0.tgz", @@ -3329,17 +2878,6 @@ "node": ">=18" } }, - "node_modules/@sindresorhus/merge-streams": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@sly-cli/sly": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@sly-cli/sly/-/sly-2.1.1.tgz", @@ -4100,16 +3638,6 @@ "@types/send": "*" } }, - "node_modules/@types/fs-extra": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", - "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", - "dev": true, - "dependencies": { - "@types/jsonfile": "*", - "@types/node": "*" - } - }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -4123,15 +3651,6 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, - "node_modules/@types/jsonfile": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", - "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -5441,6 +4960,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -6374,48 +5894,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6947,91 +6425,6 @@ "node": ">= 0.6" } }, - "node_modules/execa": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-10.0.1.tgz", - "integrity": "sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==", - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "figures": "^6.1.0", - "get-stream": "^9.0.1", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.3.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "which-command": "^0.1.0", - "yoctocolors": "^2.1.2" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/exit-hook": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-5.1.0.tgz", @@ -7176,20 +6569,6 @@ "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", "license": "MIT" }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { "version": "11.1.5", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", @@ -7456,32 +6835,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-stream/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -7796,15 +7149,6 @@ "node": ">= 6" } }, - "node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -8277,18 +7621,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -8369,6 +7701,32 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jackspeak/node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -9863,18 +9221,6 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, - "node_modules/parse-ms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -10238,21 +9584,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pretty-ms": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", - "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", - "license": "MIT", - "dependencies": { - "parse-ms": "^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -11199,6 +10530,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "engines": { "node": ">=14" }, @@ -12640,17 +11972,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -12883,6 +12204,97 @@ "vite": "*" } }, + "node_modules/vite-plugin-icons-spritesheet": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-icons-spritesheet/-/vite-plugin-icons-spritesheet-3.1.0.tgz", + "integrity": "sha512-fsdLbInegCg2PM4SoTupTe/dTbCDfCsB2HY3AyL63oXx/JYzWYW7nII2JyLWTrREvVN+PmwYIVL6/GwNQLQUBg==", + "dev": true, + "license": "MIT", + "workspaces": [ + ".", + "test-apps/*" + ], + "dependencies": { + "chalk": "^5.4.1", + "glob": "^11.0.1", + "node-html-parser": "^7.0.1", + "tinyexec": "^0.3.2" + }, + "peerDependencies": { + "vite": ">=5.2.0" + } + }, + "node_modules/vite-plugin-icons-spritesheet/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/vite-plugin-icons-spritesheet/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/vite-plugin-icons-spritesheet/node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/vite-plugin-icons-spritesheet/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/vite-plugin-icons-spritesheet/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -13628,21 +13040,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-command": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/which-command/-/which-command-0.1.0.tgz", - "integrity": "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==", - "license": "MIT", - "bin": { - "which-command": "cli.js" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sindresorhus/which-command?sponsor=1" - } - }, "node_modules/which-typed-array": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", @@ -13847,18 +13244,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", - "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/package.json b/package.json index 0b12708..f7febfc 100644 --- a/package.json +++ b/package.json @@ -17,16 +17,12 @@ "date": "2025-02-02T01:04:37Z" }, "scripts": { - "build": "run-s build:*", - "build:icons": "tsx ./other/build-icons.ts", - "build:react-router": "react-router build", - "build:server": "tsx ./other/build-server.ts", - "predev": "npm run build:icons --silent", - "dev": "cross-env NODE_ENV=development node ./server/dev-server.js", + "build": "react-router build", + "dev": "cross-env NODE_ENV=development FORCE_COLOR=true tsx watch --clear-screen=false --inspect --ignore \".cache/**\" --ignore \"app/**\" --ignore \"vite.config.ts.timestamp-*\" --ignore \"build/**\" --ignore \"node_modules/**\" ./index.ts", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint . --max-warnings 0", - "start": "cross-env NODE_ENV=production node .", + "start": "cross-env NODE_ENV=production node ./index.ts", "typecheck": "react-router typegen && tsc", "validate": "run-p lint typecheck format:check", "test": "vitest", @@ -39,8 +35,7 @@ "eslintIgnore": [ "/node_modules", "/build", - "/public/build", - "/server-build" + "/public/build" ], "prettier": "@epic-web/config/prettier", "dependencies": { @@ -57,6 +52,7 @@ "@react-router/express": "^8.4.0", "@react-router/node": "^8.4.0", "@reactuses/core": "^6.5.9", + "@sentry/node": "^11.0.0", "@sentry/profiling-node": "^11.0.0", "@sentry/react": "^11.0.0", "@tailwindcss/postcss": "^4.3.3", @@ -70,7 +66,6 @@ "cross-env": "^10.1.0", "date-fns": "^4.4.0", "dotenv": "^17.2.3", - "execa": "^10.0.1", "express": "^5.2.1", "express-rate-limit": "^8.7.0", "get-port": "^7.2.0", @@ -109,7 +104,6 @@ "@types/compression": "^1.8.1", "@types/eslint": "^9.6.1", "@types/express": "^5.0.6", - "@types/fs-extra": "^11.0.4", "@types/morgan": "^1.9.10", "@types/node": "^25.9.8", "@types/nodemailer": "^8.0.2", @@ -119,14 +113,10 @@ "@vitejs/plugin-react": "^5.1.2", "@vitest/ui": "^5.0.2", "babel-plugin-react-compiler": "^1.0.0", - "esbuild": "^0.25.11", "eslint": "^10.11.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-compiler": "^19.1.0-rc.2", - "fs-extra": "^11.4.1", - "glob": "^13.0.6", "jsdom": "^30.1.1", - "node-html-parser": "^7.0.2", "npm-run-all": "^4.1.5", "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.7.4", @@ -135,6 +125,7 @@ "typescript": "^6.0.3", "vite": "^7.3.6", "vite-env-only": "^3.0.3", + "vite-plugin-icons-spritesheet": "^3.1.0", "vitest": "^5.0.2" }, "engines": { diff --git a/server/app.ts b/server/app.ts new file mode 100644 index 0000000..983b083 --- /dev/null +++ b/server/app.ts @@ -0,0 +1,30 @@ +import { createRequestHandler } from '@react-router/express' +import express from 'express' +import { RouterContextProvider } from 'react-router' + +/** + * The React Router request handler, and nothing else. + * + * This module is the entry of the SSR build, so in production `vite build` + * bundles it into build/server/index.js and the Express app in server/index.ts + * mounts it from there. In development it is loaded through Vite instead, which + * is what lets `virtual:react-router/server-build` resolve. Keeping it in one + * module means there is a single handler definition to keep in step with the + * build. + */ +export const app = express() + +app.use( + createRequestHandler({ + mode: process.env.NODE_ENV ?? 'development', + build: () => import('virtual:react-router/server-build'), + // React Router 8 made middleware unconditional, so getLoadContext must + // return a RouterContextProvider rather than a plain object. The CSP + // nonce travels on the request header instead of through a React + // context: the Express middleware that mints it in server/index.ts is + // loaded by Node while this module is bundled by Vite, so a + // createContext() symbol created over there would be a different + // instance from the one the app's loaders import. + getLoadContext: () => new RouterContextProvider(), + }), +) diff --git a/server/dev-server.js b/server/dev-server.js deleted file mode 100644 index 0261319..0000000 --- a/server/dev-server.js +++ /dev/null @@ -1,18 +0,0 @@ -import { execa } from 'execa' - -if (process.env.NODE_ENV === 'production') { - await import('../server-build/index.js') -} else { - const command = - 'tsx watch --clear-screen=false --ignore ".cache/**" --ignore "app/**" --ignore "vite.config.ts.timestamp-*" --ignore "build/**" --ignore "node_modules/**" --inspect ./index.js' - execa(command, { - stdio: ['ignore', 'inherit', 'inherit'], - shell: true, - env: { - FORCE_COLOR: true, - ...process.env, - }, - // https://github.com/sindresorhus/execa/issues/433 - windowsHide: false, - }) -} diff --git a/server/index.ts b/server/index.ts index a0ad85b..b541d7c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,6 +1,5 @@ import crypto from 'node:crypto' import { styleText } from 'node:util' -import { createRequestHandler } from '@react-router/express' import * as Sentry from '@sentry/node' import { ip as ipAddress } from 'address' import closeWithGrace from 'close-with-grace' @@ -10,17 +9,21 @@ import rateLimit, { ipKeyGenerator } from 'express-rate-limit' import getPort, { portNumbers } from 'get-port' import helmet from 'helmet' import morgan from 'morgan' -import { type ServerBuild, RouterContextProvider } from 'react-router' -import { decodeRequestUrl } from './utils/request-url.js' +import { decodeRequestUrl } from './utils/request-url.ts' const MODE = process.env.NODE_ENV ?? 'development' const IS_PROD = MODE === 'production' const IS_DEV = MODE === 'development' const ALLOW_INDEXING = process.env.ALLOW_INDEXING !== 'false' const SENTRY_ENABLED = IS_PROD && process.env.SENTRY_DSN +// Relative to this file. `vite build` bundles server/app.ts into this entry +// and the React Router server build into a hashed sibling chunk beside it under +// build/server/assets, so the whole build/server tree has to travel together — +// see the comment on the `app` export in server/app.ts. +const BUILD_PATH = '../build/server/index.js' if (SENTRY_ENABLED) { - void import('./utils/monitoring.js').then(({ init }) => init()) + void import('./utils/monitoring.ts').then(({ init }) => init()) } const viteDevServer = IS_PROD @@ -115,8 +118,9 @@ app.use( app.use((req, _res, next) => { // The CSP nonce is generated here but consumed by app/entry.server.tsx. // It travels as a request header rather than through a React context - // because this server bundle and the app bundle do not share module - // instances. See the note on getLoadContext below. + // because this file is loaded by Node and the app is bundled by Vite, so + // the two do not share module instances. See `getLoadContext` in + // server/app.ts. req.headers['x-csp-nonce'] = crypto.randomBytes(16).toString('hex') next() }) @@ -199,45 +203,31 @@ app.use((req, res, next) => { return generalRateLimit(req, res, next) }) -async function getBuild() { - try { - const build = viteDevServer - ? await viteDevServer.ssrLoadModule('virtual:react-router/server-build') - : // @ts-ignore - the file might not exist yet but it will - await import('../build/server/index.js') - return { build: build as unknown as ServerBuild, error: null } - } catch (error) { - // Catch error and return null to make express happy and avoid an unrecoverable crash - console.error('Error creating build:', error) - return { error: error, build: null as unknown as ServerBuild } +// The React Router request handler lives in server/app.ts, which is mounted +// last so that everything above gets first refusal on the request. Express 5 +// moved to path-to-regexp v8, which rejects the bare '*' wildcard, so the +// catch-all in dev is reached through app.use rather than a route pattern. +if (viteDevServer) { + app.use(async (req, res, next) => { + try { + // Loaded through Vite rather than Node so that + // `virtual:react-router/server-build` resolves, and so that edits + // under server/ and app/ are picked up without a restart. + const source = await viteDevServer.ssrLoadModule('./server/app.ts') + return await source.app(req, res, next) + } catch (error) { + if (error instanceof Error) viteDevServer.ssrFixStacktrace(error) + next(error) + } + }) +} else { + // @ts-ignore - the built server only exists after `npm run build`. + const { app: builtApp } = (await import(BUILD_PATH)) as { + app: express.Express } + app.use(builtApp) } -// Express 5 moved to path-to-regexp v8, which rejects the bare '*' wildcard. -// The catch-all has to be named, and '/{*splat}' rather than '/*splat' so it -// also matches the site root. -app.all( - '/{*splat}', - createRequestHandler({ - // React Router 8 made middleware unconditional, so getLoadContext must - // return a RouterContextProvider rather than a plain object. The CSP - // nonce travels on the request header instead of through a React - // context: this file is bundled separately from the app by esbuild, so - // a createContext() symbol created here would be a different instance - // from the one the app's loaders import. - getLoadContext: () => new RouterContextProvider(), - mode: MODE, - build: async () => { - const { error, build } = await getBuild() - // gracefully "catch" the error - if (error) { - throw error - } - return build - }, - }), -) - const desiredPort = Number(process.env.PORT || 3000) const portToUse = await getPort({ port: portNumbers(desiredPort, desiredPort + 100), diff --git a/tests/allow-indexing.test.ts b/tests/allow-indexing.test.ts index 0a089e6..5181b50 100644 --- a/tests/allow-indexing.test.ts +++ b/tests/allow-indexing.test.ts @@ -14,12 +14,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' * The flag is read once at process start, so each state needs its own process. */ -// Two different build outputs. The server runs the esbuild bundle -// (server-build/), which is what imports the React Router server build -// (build/server/) on demand. Only the second one is worth checking for -// existence, because that is what is missing on a tree that never built. -const REACT_ROUTER_BUILD = 'build/server/index.js' -const SERVER_BUNDLE = 'server-build/index.js' +// Node runs index.ts and the server graph straight from source, so that is +// the entry. The SSR build it imports on demand is the only build output worth +// checking for existence, because that is what is missing on a tree that never +// built. +const SERVER_ENTRY = 'index.ts' +const SERVER_BUILD = 'build/server/index.js' const BOOT_TIMEOUT_MS = 30_000 /** @@ -51,9 +51,9 @@ const servers: ChildProcess[] = [] /** Boots a server with `ALLOW_INDEXING` forced to `value` (or unset). */ async function bootServer(allowIndexing: string | undefined) { - if (!existsSync(REACT_ROUTER_BUILD)) { + if (!existsSync(SERVER_BUILD)) { throw new Error( - `${REACT_ROUTER_BUILD} is missing. Run \`npm run build\` before this test; the deploy workflow's test job does this.`, + `${SERVER_BUILD} is missing. Run \`npm run build\` before this test; the deploy workflow's test job does this.`, ) } @@ -67,7 +67,7 @@ async function bootServer(allowIndexing: string | undefined) { env.ALLOW_INDEXING = allowIndexing as NodeJS.ProcessEnv['ALLOW_INDEXING'] } - const child = spawn(process.execPath, [SERVER_BUNDLE], { + const child = spawn(process.execPath, [SERVER_ENTRY], { env, stdio: ['ignore', 'pipe', 'pipe'], }) diff --git a/tests/server.smoke.test.ts b/tests/server.smoke.test.ts index a2dfa8f..06d00ce 100644 --- a/tests/server.smoke.test.ts +++ b/tests/server.smoke.test.ts @@ -112,7 +112,7 @@ beforeAll(async () => { // "sends no X-Robots-Tag by default" test below fail on their machine only. delete env.ALLOW_INDEXING - const child = spawn(process.execPath, ['server-build/index.js'], { + const child = spawn(process.execPath, ['index.ts'], { env, stdio: ['ignore', 'pipe', 'pipe'], }) diff --git a/tsconfig.json b/tsconfig.json index 01b2ca2..0377681 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,11 +4,24 @@ "compilerOptions": { "types": ["@react-router/node", "vite/client"], "rootDirs": [".", "./.react-router/types"], + // Node runs index.ts and the server graph directly, stripping the types + // itself, so anything tsc accepts has to be syntax it can erase. + // `erasableSyntaxOnly` rejects enums, namespaces and parameter + // properties: `tsx` compiles them happily in development, so nothing + // else would catch one landing in server/ and taking production down at + // boot. It is set repo-wide rather than narrowed to the server graph, so + // that app/ code is held to the same rule Vite's own transform can + // already meet. https://nodejs.org/api/typescript.html#type-stripping + // + // `verbatimModuleSyntax` is deliberately not set: it would stop the + // type-only `@/icon-name` import in app/components/ui/icon.tsx from + // being elided, and Vite cannot resolve that tsconfig paths alias. + "erasableSyntaxOnly": true, "paths": { "#app/*": ["./app/*"], "#types/*": ["./types/*"], "@/icon-name": [ - "./app/components/ui/icons/name.d.ts", + "./app/components/ui/icons/name.ts", "./types/icon-name.d.ts" ] } diff --git a/types/icon-name.d.ts b/types/icon-name.d.ts index 6f47700..7d7441b 100644 --- a/types/icon-name.d.ts +++ b/types/icon-name.d.ts @@ -1,3 +1,5 @@ -// This file is a fallback until you run npm run build:icons +// This file is a fallback for typecheck runs that have not loaded the Vite +// config, so the generated sprite and its types do not exist yet. Any Vite +// build, `npm run dev` or `npm run typecheck` regenerates them. export type IconName = string diff --git a/vite.config.ts b/vite.config.ts index 1216397..a44555f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,37 @@ import { reactRouter } from '@react-router/dev/vite' import { sentryVitePlugin } from '@sentry/vite-plugin' import react from '@vitejs/plugin-react' -import { type UserConfig } from 'vite' +import { defineConfig } from 'vite' import { envOnlyMacros } from 'vite-env-only' +import { iconsSpritesheet } from 'vite-plugin-icons-spritesheet' const MODE = process.env.NODE_ENV -export default { +export default defineConfig({ + environments: { + // server/app.ts is the entry of the SSR build rather than React + // Router's virtual server build, so the Express request handler is + // bundled into build/server/index.js and server/index.ts mounts it + // from there. It has to be set per environment: @react-router/dev + // replaces the top-level `build.rollupOptions.input` with its virtual + // server build in its own config hook. + ssr: { + build: { + rollupOptions: { + input: './server/app.ts', + }, + }, + // server/app.ts is loaded by Vite in development, and it hands + // createRequestHandler a RouterContextProvider that the handler then + // checks with `instanceof`. React Router's `development` export + // condition gives Vite and Node two different builds of the package, + // so the handler has to be bundled into the same graph as the + // provider rather than externalized into Node's. + resolve: { + noExternal: ['@react-router/express'], + }, + }, + }, build: { target: 'es2022', cssMinify: MODE === 'production', @@ -15,10 +40,16 @@ export default { external: [/node:.*/, 'fsevents'], }, + // The sprite stays a single immutable-cached request that app/root.tsx + // preloads rather than ~5 KB of data URI inlined into every document. + // The plugin also excludes the spritesheet it generates, but it does so + // by string-matching the asset's path against its outputDir, which its + // own issues report as having silently stopped matching on some + // setups. If that regresses, the sprite inlines itself and the preload + // becomes a no-op with nothing failing, so keep the case here too. assetsInlineLimit: (source: string) => { if ( source.endsWith('sprite.svg') || - source.endsWith('favicon.svg') || source.endsWith('apple-touch-icon.png') ) { return false @@ -29,6 +60,20 @@ export default { }, plugins: [ envOnlyMacros(), + iconsSpritesheet({ + inputDir: './other/svg-icons', + outputDir: './app/components/ui/icons', + fileName: 'sprite.svg', + // A .ts file, not a .d.ts: the generated module exports the icon + // name array as a value, and a const initializer in an ambient + // context is an error that only skipLibCheck hides. + typesOutputFile: './app/components/ui/icons/name.ts', + withTypes: true, + // Keep the kebab-case file names (arrow-left-outline) rather than + // the plugin's default camelCase transform, so the existing + // call sites keep working. + iconNameTransformer: (name) => name, + }), reactRouter(), react({ babel: { @@ -48,12 +93,9 @@ export default { }, }, sourcemaps: { - filesToDeleteAfterUpload: [ - './build/**/*.map', - '.server-build/**/*.map', - ], + filesToDeleteAfterUpload: ['./build/**/*.map'], }, }) : null, ], -} satisfies UserConfig +})